From a5e4caf5cb38c376f892708d8a46ad96a9cc23cc Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 12 Aug 2026 13:33:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(portal):=20=E8=AE=80=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E5=B0=B1=E8=AA=AA=E8=AE=80=E4=B8=8D=E5=88=B0=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=B8=BD=E5=9C=96=E4=B8=8D=E5=86=8D=E6=8A=8A=E3=80=8C=E8=AE=80?= =?UTF-8?q?=E4=B8=8D=E5=88=B0=E3=80=8D=E7=95=AB=E6=88=90=E3=80=8C=E4=BD=A0?= =?UTF-8?q?=E6=B2=92=E6=9C=89=E3=80=8D=EF=BC=88Arcrun#100=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 2026-08-12 打開總圖看到:「0 個實體 · 0 條關聯/知識庫還沒有任何關聯—— 上傳文件後 AI 會自動織網」。**而他庫裡有 1854 條三元組。** 那句話會叫他去做一件不需要做的事。 三個獨立的洞疊起來才變成那句謊: ① console-dashboard.ts 打 kbdb-graph-plugin 沒帶認證(同段落打 kbdb 的兩支都有帶) ② 那顆 worker 不在更新的部署清單裡 ⇒ token 一換它就落單 ③ **畫面把 null 畫成 0**——後端已經誠實回 null 了,是前端把它變成謊話 為什麼一直沒被發現:graph plugin 原本身上沒有 token ⇒ 門開著 ⇒ 沒帶也進得去。 2026-08-12 輪替後它有了 token,門關上,401 才浮出來。 📍 repo:matrix/arcrun(cypher-executor/src/routes/、console-ui/public/) 📍 票:Leo/Arcrun#100 --- .../public/console/dashboard/index.html | 9 +- console-ui/public/console/index.html | 9 +- console-ui/public/portal/index.html | 34 +++- .../src/routes/console-dashboard.ts | 49 +++++- cypher-executor/src/routes/kbdb-proxy.ts | 18 ++- cypher-executor/src/routes/portal-data.ts | 80 +++++++++- .../tests/graph-stats-honesty.test.ts | 147 ++++++++++++++++++ cypher-executor/tests/portal-data.test.ts | 88 +++++++++++ cypher-executor/wrangler.test.toml | 4 + 9 files changed, 410 insertions(+), 28 deletions(-) create mode 100644 cypher-executor/tests/graph-stats-honesty.test.ts diff --git a/console-ui/public/console/dashboard/index.html b/console-ui/public/console/dashboard/index.html index 9c82770..e2706db 100644 --- a/console-ui/public/console/dashboard/index.html +++ b/console-ui/public/console/dashboard/index.html @@ -277,9 +277,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { } else { rows.push(sysRow('語意嵌入', '狀態讀不到', 'off')); } - rows.push(sys.graph && sys.graph.ok - ? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok') - : sysRow('知識圖譜', '● 打不通', 'bad')); + // Arcrun#100:「服務活著嗎」與「庫裡有幾條」拆兩列。混一列時,圖服務打不通會把 + // 「其實有 1854 條」整個吞掉,畫面看起來就像知識庫是空的。數字讀不到寫「讀不到」,不寫 0。 + var gOk = !!(sys.graph && sys.graph.ok); + var tri = sys.graph && sys.graph.triplets != null ? sys.graph.triplets : null; + rows.push(sysRow('知識圖譜服務', gOk ? '● 正常' : '● 打不通', gOk ? 'ok' : 'bad')); + rows.push(sysRow('三元組(關聯)', tri == null ? '讀不到' : tri.toLocaleString() + ' 條', tri == null ? 'off' : '')); rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : '')); // 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的; // 三元組/已嵌入 已各有一列) diff --git a/console-ui/public/console/index.html b/console-ui/public/console/index.html index f3e0235..3be1b74 100644 --- a/console-ui/public/console/index.html +++ b/console-ui/public/console/index.html @@ -934,14 +934,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { fetch(API_BASE + '/console/kb-scale-data') .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { - if (!d) return; - var n = function (v) { return v == null ? '?' : v.toLocaleString(); }; + // #100:讀不到就明說讀不到(原本靜默 return,會把上一輪的舊數字留在畫面上) + if (!d) { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; return; } + var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); }; var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)]; var latest = d.wiki_card_latest_ago_minutes; $('se-scale').textContent = '精耕層 ' + parts.join('・') + (latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : ''); }) - .catch(function () { /* 規模感拿不到不擋搜尋 */ }); + .catch(function () { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; }); } $('se-sem').addEventListener('click', function () { S.semantic = !S.semantic; @@ -1504,7 +1505,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { ]).then(function (rs) { var svc = rs[0].status === 'fulfilled' ? rs[0].value : {}; var kb = rs[1].status === 'fulfilled' ? rs[1].value : null; - var n = function (v) { return v == null ? '?' : v.toLocaleString(); }; + var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); }; var rows = ''; rows += '
服務' + esc(svc.service || 'arcrun-cypher-executor') + '
'; rows += '
版本' + esc(svc.version || '—') + '
'; diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index c09c43c..06ed13c 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -1486,7 +1486,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { $('se-q').value = name; doGraphSearch(name); } + // 讀不到就明說「讀不到」——標題列**絕不**留著 0 或舊數字(Arcrun#100:leo 看到 + // 「0 個實體・0 條關聯」以為要去上傳文件,其實庫裡有 1854 條,只是這支讀失敗了)。 + function mapUnavailable(html) { + $('map-meta').textContent = '讀不到'; + $('map-box').innerHTML = '
' + html + '
'; + } function loadMap() { + $('map-meta').textContent = ''; $('map-box').innerHTML = '
載入總圖中…
'; $('map-md-link').innerHTML = SOURCE_WEB_BASE ? ':00-MAP.md ↗' @@ -1495,14 +1502,31 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; - if (!x.ok) { $('map-box').innerHTML = '
' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')')) + '
'; return; } - var nodes = x.d.nodes || []; - var edges = x.d.edges || []; - $('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : ''); + if (!x.ok) { mapUnavailable(esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')'))); return; } + // Arcrun#100:「0」只准在後端確認過真的是 0 的時候出現。 + // 形狀不對 → 讀不到(不是空庫);nodes 為空但 empty_confirmed 不成立 → 讀不到。 + if (!Array.isArray(x.d.nodes) || !Array.isArray(x.d.edges)) { + mapUnavailable('總圖回應格式不對——沒有拿到關聯資料。這不代表知識庫是空的。'); + return; + } + var nodes = x.d.nodes, edges = x.d.edges; + var total = typeof x.d.triplets_total === 'number' ? x.d.triplets_total : null; + if (!nodes.length && x.d.empty_confirmed !== true) { + mapUnavailable(x.d.empty_reason === 'scope_mismatch' + ? '讀不到你這個帳號的關聯資料——知識庫裡有三元組' + + (total ? '(本帳號範圍算到 ' + total.toLocaleString() + ' 條)' : '') + + ',但這張圖一條都抽不出來。
' + + '這不是「還沒有關聯」,不用去上傳文件;比較像資料的歸屬範圍對不上,請通知管理員。' + : '讀不到知識庫的關聯資料,無法確認庫裡有沒有關聯。
' + + '這不是「還沒有關聯」,不用去上傳文件——是這次讀取失敗,請稍後重整或通知管理員。'); + return; + } + $('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + + (total !== null && x.d.truncated ? '(全庫共 ' + total.toLocaleString() + ' 條,已達單次上限)' : x.d.truncated ? '・已達上限截斷' : ''); if (!nodes.length) { $('map-box').innerHTML = '
知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。
'; return; } renderMap(nodes, edges); }) - .catch(function (e) { $('map-box').innerHTML = '
請求失敗:' + esc(friendlyErr(e)) + '
'; }); + .catch(function (e) { mapUnavailable('請求失敗:' + esc(friendlyErr(e))); }); } function renderMap(nodes, edges) { var N = nodes.length; diff --git a/cypher-executor/src/routes/console-dashboard.ts b/cypher-executor/src/routes/console-dashboard.ts index 0919f69..82af101 100644 --- a/cypher-executor/src/routes/console-dashboard.ts +++ b/cypher-executor/src/routes/console-dashboard.ts @@ -48,7 +48,7 @@ */ import { Hono } from 'hono'; import type { Bindings } from '../types'; -import { kbdbBase, graphBase } from './kbdb-proxy'; +import { kbdbBase, graphBase, graphHeaders } from './kbdb-proxy'; import { validateConsoleSession } from './console-auth'; import { type KbdbEntry, @@ -104,6 +104,31 @@ async function fetchJson(url: string, headers?: Record): Prom } } +/** + * 本租戶三元組的**真實總數**(null = 讀不到,畫面要顯示「讀不到」而非 0)。 + * + * 🔴 Arcrun#100:不可以拿 graph-plugin `/triplets/stats` 的 `total` 當數量。 + * 那支的 `total` 是**分頁長度**不是 COUNT——它走 `/records/by-template/triplet`(KBDB 端 + * `searchByTemplate` 預設 limit=100、硬上限 500)且不帶 owner 過濾,所以 1854 條的庫 + * 只會回 100。修好 401 之後若還讀它,畫面會從「0」變成「100」——一樣是假的。 + * 真相源=KBDB `/records/triplet-stats`(真 SQL COUNT(*)、依 owner_id 過濾、無上限), + * 回 `{ success, stats: [{ library, triplet_count }] }`,加總即全庫條數。 + */ +async function fetchTripletTotal(env: Bindings, tenant: string): Promise { + const { base, headers } = kbdbBase(env); + const data = await fetchJson<{ stats?: { triplet_count?: unknown }[] }>( + `${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant)}`, + headers, + ); + if (!data || !Array.isArray(data.stats)) return null; + let total = 0; + for (const row of data.stats) { + if (typeof row?.triplet_count !== 'number') return null; // 形狀不對 → 誠實回讀不到,不半信半疑加總 + total += row.triplet_count; + } + return total; +} + /** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */ async function fetchEntryTotal(env: Bindings, filters: Record): Promise { const { base, headers } = kbdbBase(env); @@ -254,6 +279,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => { kbdbHealth, embedStatus, graphStats, + tripletTotal, entriesTotal, wikiCardTotal, workflowTotal, @@ -265,7 +291,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => { cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)), fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders), fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders), - fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`), + // graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。 + // headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。 + fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>( + `${graphUrl}/triplets/stats`, + graphHeaders(c.env), + ), + fetchTripletTotal(c.env, tenant), // owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732) fetchEntryTotal(c.env, { owner_id: tenant }), fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }), @@ -400,13 +432,14 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => { embed: embedStatus ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null } : null, - graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null }, + // ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關) + graph: { ok: graphStats !== null, triplets: tripletTotal }, workflow_total: workflowTotal, }, kb: { entries_total: entriesTotal, wiki_card_total: wikiCardTotal, - triplets_total: graphStats?.total ?? null, + triplets_total: tripletTotal, }, generated_at: new Date(now).toISOString(), }); @@ -420,15 +453,15 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => { consoleDashboardRouter.get('/console/kb-scale-data', async (c) => { const tenant = c.env.CONSOLE_TENANT || 'leo'; const { base, headers } = kbdbBase(c.env); - const graphUrl = graphBase(c.env); const now = Date.now(); - const [wikiCards, graphStats, embedStatus] = await Promise.all([ + const [wikiCards, tripletTotal, embedStatus] = await Promise.all([ // limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」 fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>( `${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`, headers, ), - fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`), + // #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註) + fetchTripletTotal(c.env, tenant), fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers), ]); const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null); @@ -436,7 +469,7 @@ consoleDashboardRouter.get('/console/kb-scale-data', async (c) => { return c.json({ wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null, wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs), - triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null, + triplets_total: tripletTotal, embedded: embedStatus?.embedded ?? null, embed_enabled: embedStatus ? embedStatus.enabled === true : null, generated_at: new Date(now).toISOString(), diff --git a/cypher-executor/src/routes/kbdb-proxy.ts b/cypher-executor/src/routes/kbdb-proxy.ts index 62a3ef3..0074369 100644 --- a/cypher-executor/src/routes/kbdb-proxy.ts +++ b/cypher-executor/src/routes/kbdb-proxy.ts @@ -223,13 +223,27 @@ export function graphBase(env: Bindings): string { return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`; } +/** + * kbdb-graph-plugin 的 internal headers。**打 plugin 一律用這支,不要各自手拼**(Arcrun#100)。 + * + * plugin 端(kbdb-graph-plugin/src/index.ts)對 `/triplets` `/graph` `/search` `/entities` + * 四個前綴掛了 Bearer 閘:設了 KBDB_INTERNAL_TOKEN 就必須帶,否則一律 401。 + * 原本三處手拼(本檔 neighbors、portal-data neighbors、console-dashboard 兩支 stats), + * 前兩處帶了、後兩處漏了 → `/triplets/stats` 永遠 401 → 前端「三元組 0」。 + * 收斂成一支函式=新的呼叫點不可能再漏(漂移的根,不是那兩行本身)。 + */ +export function graphHeaders(env: Bindings): Record { + const headers: Record = {}; + if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`; + return headers; +} + // GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。 // 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。 kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => { if (!tenant(c)) return c.json(NEED_KEY, 401); const base = graphBase(c.env); - const headers: Record = {}; - if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`; + const headers = graphHeaders(c.env); try { const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 23e1f5d..3ffc3bf 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -24,7 +24,7 @@ import { Hono } from 'hono'; import type { Context } from 'hono'; import type { Bindings } from '../types'; import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal'; -import { graphBase } from './kbdb-proxy'; +import { graphBase, graphHeaders } from './kbdb-proxy'; import { executeWebhookGraph } from '../actions/webhook-handlers'; export const portalDataRouter = new Hono<{ Bindings: Bindings }>(); @@ -186,6 +186,45 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri return hits.reduce((a, b) => a.length <= b.length ? a : b); } +/** + * 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端 + * `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。 + */ +async function tripletCount(env: Bindings, owner: string): Promise { + try { + const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`); + if (!res.ok) return null; + const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null; + if (!body || !Array.isArray(body.stats)) return null; + let total = 0; + for (const row of body.stats) { + if (typeof row?.triplet_count !== 'number') return null; + total += row.triplet_count; + } + return total; + } catch { + return null; + } +} + +/** + * 三元組普查(Arcrun#100)——回答總圖那句「知識庫還沒有任何關聯」到底能不能講。 + * + * leo 的原則(已寫在 portal.ts §②.5 daemon diagnostics):**「不要讓『查不到』和『沒有』 + * 長得一樣」**。t161 前科:手補的 record owner_id 存成 None ⇒ 全量查得到、按 owner_id 過濾 + * 的畫面永遠空——比真的沒資料更難查。所以本租戶數為 0 時**再花一次查詢換一條路徑** + * (同一支端點但不帶 owner),問「這個庫到底有沒有三元組」: + * owned>0 → 有資料 + * owned=0 且 any=0 → 真的空(此時、也只有此時,畫面才准印 0) + * owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到 + * owned=null → 讀不到 → 畫面說讀不到 + */ +async function tripletCensus(env: Bindings, tenant: string): Promise<{ owned: number | null; any: number | null }> { + const owned = await tripletCount(env, tenant); + if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次 + return { owned, any: await tripletCount(env, '') }; +} + /** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */ async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise { try { @@ -343,8 +382,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => // ② plugin fallback(Mira/leo21c 相容) const base = graphBase(c.env); - const headers: Record = {}; - if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`; + const headers = graphHeaders(c.env); try { const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers }); if (!res.ok) { @@ -383,14 +421,23 @@ portalDataRouter.get('/portal/data/graph/overview', (c) => return c.json({ error: '無知識圖譜檢視權限' }, 403); } const tenant = portalTenant(c.env); - const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`); + const [res, census] = await Promise.all([ + kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}&limit=500`), + tripletCensus(c.env, tenant), + ]); + const tripletsTotal = census.owned; if (!res.ok) { return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); } const body = (await res.json().catch(() => null)) as | { records?: { values?: Record }[] } | null; - const records = body && Array.isArray(body.records) ? body.records : []; + // #100:形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖, + // 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。 + if (!body || !Array.isArray(body.records)) { + return c.json({ error: '三元組讀取失敗:KBDB 回應不是預期的 records 清單' }, 502); + } + const records = body.records; const EDGE_CAP = 500; const seen = new Set(); const edges: { subject: string; predicate: string; object: string }[] = []; @@ -413,7 +460,28 @@ portalDataRouter.get('/portal/data/graph/overview', (c) => degree.set(o, (degree.get(o) ?? 0) + 1); } const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d })); - return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated }); + // #100:一張空圖有三種成因,前端必須分得出來(判準留在 server,不留給前端猜)—— + // confirmed_empty :本租戶真的一條都沒有,全庫也沒有 → 才准印「0 個實體・0 條關聯」 + // scope_mismatch :全庫有、本租戶查不到 → owner_id/範圍對不上,不是空庫(t161 前科) + // unreadable :連條數都讀不到 → 只能說讀不到 + let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null; + if (nodes.length === 0) { + if (census.owned === null) emptyReason = 'unreadable'; + else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊 + else if (census.any === null) emptyReason = 'unreadable'; + else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty'; + } + return c.json({ + nodes, + edges, + node_count: nodes.length, + edge_count: edges.length, + // 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部 + truncated: truncated || records.length >= 500, + triplets_total: tripletsTotal, + empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty', + empty_reason: emptyReason, + }); }), ); diff --git a/cypher-executor/tests/graph-stats-honesty.test.ts b/cypher-executor/tests/graph-stats-honesty.test.ts new file mode 100644 index 0000000..16f3b76 --- /dev/null +++ b/cypher-executor/tests/graph-stats-honesty.test.ts @@ -0,0 +1,147 @@ +/** + * Arcrun#100 — 「畫面上的 0,只准在真的是 0 的時候出現」 + * + * 病灶(leo 實遇):總圖頁寫「0 個實體・0 條關聯/知識庫還沒有任何關聯——上傳文件後 AI 會 + * 自動織網」,而他庫裡有 1854 條三元組。那句話會叫他去做一件不需要做的事。 + * + * 本檔釘住三件事: + * ① kbdb-graph-plugin 的 `/triplets` 前綴掛 Bearer 閘,cypher 打它**一定要帶 token** + * (console-dashboard 兩支 stats 原本漏帶 → 永遠 401)。 + * ② 三元組數量的真相源=KBDB `/records/triplet-stats`(真 SQL COUNT、依 owner 過濾), + * **不是** plugin `/triplets/stats` 的 `total`——那是分頁長度(KBDB 端上限 100/500), + * 1854 條的庫只會回 100。只修 401 不換來源=把「0」換成「100」,一樣是假的。 + * ③ 讀不到一律 null / 502 / empty_confirmed=false,**絕不退化成 0**。 + * + * KBDB/graph-plugin 都打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test、 + * KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。 + */ +import { SELF, env, fetchMock } from 'cloudflare:test'; +import { beforeAll, afterEach, describe, it, expect } from 'vitest'; +import { graphHeaders, graphBase } from '../src/routes/kbdb-proxy'; +import type { Bindings } from '../src/types'; + +const KBDB = 'https://kbdb.test'; +const GRAPH = 'https://graph.test'; +const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT + +beforeAll(() => { + fetchMock.activate(); + fetchMock.disableNetConnect(); +}); +afterEach(() => fetchMock.assertNoPendingInterceptors()); + +/** KBDB `/records/triplet-stats` — 真 COUNT 的形狀:{ success, stats: [{library, triplet_count}] } */ +function mockTripletStats(rows: { library: string; triplet_count: number }[] | null, status = 200) { + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(status, rows === null ? { success: false, error: 'boom' } : { success: true, stats: rows }); +} + +// ═══════════════ 1. graphHeaders:打 plugin 的 header 只有一份 ═══════════════ + +describe('graphHeaders(#100 漂移的根:三處手拼 → 一支函式)', () => { + it('有 KBDB_INTERNAL_TOKEN → 帶 Bearer(plugin 的 /triplets /graph /search /entities 全靠它)', () => { + expect(graphHeaders({ KBDB_INTERNAL_TOKEN: 'tok-abc' } as unknown as Bindings)).toEqual({ + Authorization: 'Bearer tok-abc', + }); + }); + + it('沒設 token → 空 headers(plugin 未設 secret 時本來就開放,不硬塞空 Bearer)', () => { + expect(graphHeaders({} as unknown as Bindings)).toEqual({}); + }); + + it('graphBase 仍照舊(KBDB_GRAPH_URL 優先、去尾斜線)', () => { + expect(graphBase({ KBDB_GRAPH_URL: 'https://graph.test/' } as unknown as Bindings)).toBe('https://graph.test'); + }); +}); + +// ═══════════════ 2. /console/kb-scale-data:數字對得上庫裡真正的數量 ═══════════════ + +describe('GET /console/kb-scale-data — 三元組數=KBDB 真 COUNT', () => { + it('庫裡 1854 條(跨三個庫)→ triplets_total 回 1854,不是 plugin 的分頁長度 100', async () => { + mockTripletStats([ + { library: 'general', triplet_count: 1200 }, + { library: 'finance', triplet_count: 600 }, + { library: 'ops', triplet_count: 54 }, + ]); + const res = await SELF.fetch('http://localhost/console/kb-scale-data'); + expect(res.status).toBe(200); + const d = (await res.json()) as { triplets_total: number | null }; + expect(d.triplets_total).toBe(1854); + }); + + it('反向:triplet-stats 讀不到(500)→ triplets_total = null,**不是 0**', async () => { + mockTripletStats(null, 500); + const res = await SELF.fetch('http://localhost/console/kb-scale-data'); + expect(res.status).toBe(200); + const d = (await res.json()) as { triplets_total: number | null }; + expect(d.triplets_total).toBeNull(); + expect(d.triplets_total).not.toBe(0); // 這一行就是 #100 的整個重點 + }); + + it('反向:回應形狀不對(stats 不是陣列)→ null,不半信半疑當 0', async () => { + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: 'oops' }); + const res = await SELF.fetch('http://localhost/console/kb-scale-data'); + const d = (await res.json()) as { triplets_total: number | null }; + expect(d.triplets_total).toBeNull(); + }); + + it('真的是 0(庫存在但沒有任何三元組)→ 誠實回 0(0 只在這種時候出現)', async () => { + mockTripletStats([]); + const res = await SELF.fetch('http://localhost/console/kb-scale-data'); + const d = (await res.json()) as { triplets_total: number | null }; + expect(d.triplets_total).toBe(0); + }); + + it('kb-scale-data 不再打 graph-plugin(沒有 plugin interceptor 也能拿到數字)', async () => { + mockTripletStats([{ library: 'general', triplet_count: 7 }]); + const res = await SELF.fetch('http://localhost/console/kb-scale-data'); + const d = (await res.json()) as { triplets_total: number | null }; + expect(d.triplets_total).toBe(7); // 打 GRAPH 的話 disableNetConnect 會讓它變 null + }); +}); + +// ═══════════════ 3. /console/dashboard-data:燈號問 plugin、數字問 KBDB ═══════════════ + +describe('GET /console/dashboard-data — 圖服務健康 vs 三元組數量是兩件事', () => { + it('打 plugin /triplets/stats **有帶 Bearer** → graph.ok=true;數量仍取 KBDB 真 COUNT', async () => { + // headers matcher:漏帶 Authorization 就配不到這個 interceptor → 請求失敗 → graph.ok=false + fetchMock + .get(GRAPH) + .intercept({ + path: (p: string) => p.startsWith('/triplets/stats'), + method: 'GET', + headers: { authorization: `Bearer ${env.KBDB_INTERNAL_TOKEN}` }, + }) + .reply(200, { total: 100 }); // plugin 的分頁長度,故意與真值不同 + mockTripletStats([{ library: 'general', triplet_count: 1854 }]); + const res = await SELF.fetch('http://localhost/console/dashboard-data'); + expect(res.status).toBe(200); + const d = (await res.json()) as { + system: { graph: { ok: boolean; triplets: number | null } }; + kb: { triplets_total: number | null }; + }; + expect(d.system.graph.ok).toBe(true); // 帶了 token 才會是 true(#100 迴歸閘) + expect(d.system.graph.triplets).toBe(1854); // 不是 plugin 的 100 + expect(d.kb.triplets_total).toBe(1854); + }); + + it('反向:plugin 打不通 → graph.ok=false,但三元組數照樣是真的(不被服務狀態吞掉)', async () => { + mockTripletStats([{ library: 'general', triplet_count: 1854 }]); + const res = await SELF.fetch('http://localhost/console/dashboard-data'); + const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } }; + expect(d.system.graph.ok).toBe(false); + expect(d.system.graph.triplets).toBe(1854); + }); + + it('反向:兩邊都讀不到 → ok=false + triplets=null(不是 0)', async () => { + const res = await SELF.fetch('http://localhost/console/dashboard-data'); + const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } }; + expect(d.system.graph.ok).toBe(false); + expect(d.system.graph.triplets).toBeNull(); + }); +}); diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index 28bedba..2367df7 100644 --- a/cypher-executor/tests/portal-data.test.ts +++ b/cypher-executor/tests/portal-data.test.ts @@ -942,3 +942,91 @@ describe('GET /portal/daemon/diagnostics(t213 daemon 版)', () => { expect(JSON.stringify(body.notes)).not.toContain('截圖'); }); }); + +// ═══ Arcrun#100: 總圖的「0」只准在真的是 0 的時候出現 ═══ + +describe('GET /portal/data/graph/overview(#100 空圖三態)', () => { + /** KBDB `/records/triplet-stats`:帶 owner 與不帶 owner 是兩條不同路徑,分別攔。 */ + function mockCount(scoped: number | null, global?: number | null) { + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith(`/records/triplet-stats?owner_id=${TENANT}`), method: 'GET' }) + .reply(scoped === null ? 500 : 200, scoped === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: scoped }] }); + if (global !== undefined) { + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p === '/records/triplet-stats?owner_id=', method: 'GET' }) + .reply(global === null ? 500 : 200, global === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: global }] }); + } + } + function mockTriplets(body: object, status = 200) { + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' }) + .reply(status, body); + } + async function overview(token: string) { + await seedSession(token, `rec_${token}`); + mockGetRecord(`rec_${token}`, userValues({ libraries: '["*"]', role: 'admin' })); + return get('/portal/data/graph/overview', { Authorization: `Bearer ${token}` }); + } + + it('有資料 → 照常回圖,並附上全庫真實條數', async () => { + mockTriplets({ success: true, records: [{ values: { subject: 'A', predicate: '連到', object: 'B' } }] }); + mockCount(1854); + const res = await overview('tok-ov1'); + expect(res.status).toBe(200); + const d = (await res.json()) as { node_count: number; triplets_total: number; empty_confirmed: boolean }; + expect(d.node_count).toBe(2); + expect(d.triplets_total).toBe(1854); + expect(d.empty_confirmed).toBe(true); + }); + + it('真的空(本租戶 0、全庫也 0)→ empty_confirmed=true,畫面才准印 0', async () => { + mockTriplets({ success: true, records: [] }); + mockCount(0, 0); + const res = await overview('tok-ov2'); + const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string }; + expect(d.node_count).toBe(0); + expect(d.empty_confirmed).toBe(true); + expect(d.empty_reason).toBe('confirmed_empty'); + }); + + it('🔴 反向:本租戶查到 0、全庫卻有 1854(t161 owner_id 對不上)→ 不准說空,回 scope_mismatch', async () => { + mockTriplets({ success: true, records: [] }); + mockCount(0, 1854); + const res = await overview('tok-ov3'); + const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string }; + expect(d.empty_confirmed).toBe(false); + expect(d.empty_reason).toBe('scope_mismatch'); + }); + + it('🔴 反向:條數讀不到 → unreadable(不是 confirmed_empty,畫面顯示「讀不到」)', async () => { + mockTriplets({ success: true, records: [] }); + mockCount(null); + const res = await overview('tok-ov4'); + const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string; triplets_total: number | null }; + expect(d.empty_confirmed).toBe(false); + expect(d.empty_reason).toBe('unreadable'); + expect(d.triplets_total).toBeNull(); + }); + + it('🔴 反向:有條數卻一條邊都抽不出來 → scope_mismatch,不是空庫', async () => { + mockTriplets({ success: true, records: [{ values: { subject: '', object: '' } }] }); + mockCount(1854); + const res = await overview('tok-ov5'); + const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string }; + expect(d.node_count).toBe(0); + expect(d.empty_reason).toBe('scope_mismatch'); + expect(d.empty_confirmed).toBe(false); + }); + + it('🔴 反向:KBDB 回應形狀不對(沒有 records 陣列)→ 502,不再回一張空圖', async () => { + mockTriplets({ success: true, items: [] }); // 欄位名不對=讀不出來 + mockCount(1854); + const res = await overview('tok-ov6'); + expect(res.status).toBe(502); + const d = (await res.json()) as { error: string }; + expect(d.error).toContain('三元組讀取失敗'); + }); +}); diff --git a/cypher-executor/wrangler.test.toml b/cypher-executor/wrangler.test.toml index 37772c2..2a23def 100644 --- a/cypher-executor/wrangler.test.toml +++ b/cypher-executor/wrangler.test.toml @@ -49,6 +49,10 @@ KBDB_BASE_URL = "https://kbdb.test" CONSOLE_TENANT = "leo" # portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連) KBDB_GRAPH_URL = "https://graph.test" +# Arcrun#100:kbdb-graph-plugin 對 /triplets /graph /search /entities 掛 Bearer 閘。測試環境要有 +# 這把(明顯的假字串、非真實金鑰)才驗得出「cypher 打 plugin 有沒有帶 token」——原本兩支 +# /triplets/stats 漏帶 → 永遠 401 → 前端「三元組 0」。真實部署仍走 wrangler secret put。 +KBDB_INTERNAL_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,同上方 CF_SECRETS_API_TOKEN 慣例 # D61(ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers # Scripts secrets 管理 API(https://api.cloudflare.com/...),authStoreWritable() 只看這兩項 # 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的