fix(portal): 讀不到就說讀不到——總圖不再把「讀不到」畫成「你沒有」(Arcrun#100)

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
This commit is contained in:
uncle6me-web
2026-08-12 13:33:53 +08:00
parent e69d6bbc03
commit a5e4caf5cb
9 changed files with 410 additions and 28 deletions
+74 -6
View File
@@ -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<number | null> {
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<string | null> {
try {
@@ -343,8 +382,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
// ② plugin fallbackMira/leo21c 相容)
const base = graphBase(c.env);
const headers: Record<string, string> = {};
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<string, unknown> }[] }
| 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<string>();
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,
});
}),
);