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:
@@ -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<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 回應裡的 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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user