feat(portal): GET /portal/data/diagnostics —— 檢修孔聚合端點

leo 2026-08-07 直接指令:「一顆按鈕在設定裡,按鈕下載一個檔案,把檔案發給我,你看那個
檔」。本端點是那個檔的資料來源:聚合 embed 模組健康狀態(module_enabled/cards_embedded/
cards_pending/self_test)、知識庫規模(library_count/triplet_count)、bundle_version、
instance_url。

紅線落實:
- 只轉發數字/布林/字串狀態,KBDB /map 回應裡的 narrative/top_entities(卡片內容)讀出
  triplet_count 後即丟棄,測試 portal-data.test.ts 新增案專門斷言回應不含內容字樣。
- 認證沿用既有 requirePortalUser session 閘,不對外公開。

3 個新測試全綠(未登入 401/完整聚合含隱私斷言/embed 未開時誠實回 false 不假裝)。
既有 1 個失敗案(/portal HTML 殼 404)為 stash 驗證過的既有失敗,與本次改動無關。
This commit is contained in:
uncle6me-web
2026-08-07 18:38:03 +08:00
parent 9344562258
commit 83aa1f6bb2
2 changed files with 167 additions and 0 deletions
+89
View File
@@ -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 存在的前提)、
// 已嵌入/待嵌入卡片數、以及 embedSelfTestKBDB #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<string, unknown> = { 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 回應裡的 narrativetop_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_pendingcards_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,
});
}),
);
+78
View File
@@ -711,3 +711,81 @@ describe('dedupeSourcesByPaget129 出處去重)', () => {
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 回應裡的 narrativetop_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();
});
});