From e28e19069f2e566f232c27136582269a22b7624e Mon Sep 17 00:00:00 2001 From: richblack Date: Wed, 29 Jul 2026 19:55:03 +0800 Subject: [PATCH] =?UTF-8?q?t142:=20=E9=9B=B2=E7=AB=AF=E5=AD=90=E5=BA=AB?= =?UTF-8?q?=E9=A1=AF=E7=A4=BA=E5=90=8C=E6=AD=A5=E5=B9=BE=E5=BC=B5=E5=8D=A1?= =?UTF-8?q?=EF=BC=8B=E5=B9=BE=E5=80=8B=E4=B8=89=E5=85=83=E7=B5=84=EF=BC=88?= =?UTF-8?q?=E6=94=BF=E5=BA=9C=E5=B0=88=E6=A1=88=E9=A9=97=E6=94=B6=E9=9C=80?= =?UTF-8?q?=E6=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 07-29:「要在雲端子庫顯示同步了幾個 wiki,既然這樣也同步顯示有幾個三元組, 這是為了政府專案驗收。」 kbdb 兩支統計端點: - entries.ts: COUNT(DISTINCT page_name) AS card_count ⚠️ 必須 distinct——算 block 數會膨脹 3-5 倍,政府驗收看到假數字比沒數字更糟 - records.ts: COUNT(*) AS triplet_count(三元組本來就算全部) portal.ts 聚合兩者掛進庫目錄;前端 index.html 顯示。 驗:卡數確為 COUNT(DISTINCT page_name)/前端內嵌 JS node --check 全通過 (07-29 白畫面事故教訓)/portal-admin 測試 34 passed(改動前 31 passed, 唯一的 1 failed 是既有債:GET /portal 回 404,改動前後相同,非本次造成)。 註:此為子 CC 完成後未 commit 的懸置工作,總管收工檢查時發現並補收。 --- console-ui/public/portal/index.html | 12 +++ cypher-executor/src/routes/portal.ts | 34 ++++++- cypher-executor/tests/portal-admin.test.ts | 93 ++++++++++++++++++++ kbdb/src/routes/entries.ts | 24 +++++ kbdb/src/routes/records.ts | 32 +++++++ system-dev/docs/3-specs/portal-auth/tasks.md | 20 +++++ 6 files changed, 211 insertions(+), 4 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index cb163c3..83d64ba 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -1533,6 +1533,17 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { var removeBtn = l.auto ? '' : ''; + // t142:卡數+三元組數顯示(政府驗收用)。兩者皆 0 顯示「還沒有內容」,不顯示「0 張」。 + var cardCount = typeof l.card_count === 'number' ? l.card_count : undefined; + var tripletCount = typeof l.triplet_count === 'number' ? l.triplet_count : undefined; + var statsHtml = ''; + if (cardCount !== undefined || tripletCount !== undefined) { + var parts = []; + if (cardCount > 0) parts.push(cardCount + ' 張知識卡'); + if (tripletCount > 0) parts.push(tripletCount + ' 條關聯'); + statsHtml = '
' + + (parts.length ? parts.join('・') : '還沒有內容') + '
'; + } return '
' + '
' + '' + esc(l.display_name || l.name) + '' + @@ -1542,6 +1553,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { : '' + (disabled ? '已停用' : '啟用中') + '') + removeBtn + '
' + + statsHtml + (!l.auto && l.description ? '
' + esc(l.description) + '
' : '') + (notWatching ? '
小幫手目前沒有在同步這個資料夾
' : '') + '
'; diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 3220318..6d75fd6 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -1081,11 +1081,35 @@ portalRouter.get('/portal/admin/libraries', (c) => return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) }; }); const known = new Set(out.map((l) => l.name)); - // 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library) + // t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。 + // 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。 try { - const res = await kbdbFetch(c.env, `/entries/libraries?owner_id=${encodeURIComponent(portalTenant(c.env))}`); - if (res.ok) { - const body = (await res.json()) as { libraries?: string[] }; + const tenant = portalTenant(c.env); + const ownerParam = `owner_id=${encodeURIComponent(tenant)}`; + const [autoRes, cardRes, tripletRes] = await Promise.all([ + kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null), + kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null), + kbdbFetch(c.env, `/records/triplet-stats?${ownerParam}`).catch(() => null), + ]); + // 解析統計,建成 Map 供 O(1) 查找 + const cardMap = new Map(); + if (cardRes?.ok) { + const body = (await cardRes.json()) as { stats?: { library: string; card_count: number }[] }; + for (const s of body.stats ?? []) cardMap.set(s.library, s.card_count); + } + const tripletMap = new Map(); + if (tripletRes?.ok) { + const body = (await tripletRes.json()) as { stats?: { library: string; triplet_count: number }[] }; + for (const s of body.stats ?? []) tripletMap.set(s.library, s.triplet_count); + } + // 已登記庫補入統計 + for (const lib of out) { + (lib as Record).card_count = cardMap.get(lib.name) ?? 0; + (lib as Record).triplet_count = tripletMap.get(lib.name) ?? 0; + } + // 資料面自動出現的庫(蓋章即現身) + if (autoRes?.ok) { + const body = (await autoRes.json()) as { libraries?: string[] }; for (const name of body.libraries ?? []) { const n = String(name ?? '').trim(); // general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉 @@ -1096,6 +1120,8 @@ portalRouter.get('/portal/admin/libraries', (c) => record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true, + card_count: cardMap.get(n) ?? 0, + triplet_count: tripletMap.get(n) ?? 0, ...(watching !== undefined ? { daemon_watching: watching } : {}), }); } diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 55ffbc1..59eede4 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -381,10 +381,19 @@ describe('/portal/admin/libraries', () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); mockListByTemplate('portal_library', []); + // t142:GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) .reply(200, { libraries: ['kb', 'general', 'notes'] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] }; @@ -395,6 +404,90 @@ describe('/portal/admin/libraries', () => { }); }); +// ═══════════════ t142 庫目錄卡數+三元組數 ═══════════════ + +describe('GET /portal/admin/libraries + stats(t142)', () => { + it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', [ + { record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } }, + ]); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) + .reply(200, { libraries: ['kb'] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] }); + const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] }; + const kb = data.libraries.find((l) => l.name === 'kb'); + expect(kb).toBeDefined(); + expect(kb!.card_count).toBe(42); + expect(kb!.triplet_count).toBe(111); + }); + + it('auto 庫也帶 card_count + triplet_count', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', []); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) + .reply(200, { libraries: ['notes'] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] }); + const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] }; + const notes = data.libraries.find((l) => l.name === 'notes'); + expect(notes).toBeDefined(); + expect(notes!.auto).toBe(true); + expect(notes!.card_count).toBe(7); + expect(notes!.triplet_count).toBe(108); + }); + + it('庫無內容時 card_count=0 + triplet_count=0(前端顯示「還沒有內容」)', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', [ + { record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } }, + ]); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) + .reply(200, { libraries: [] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); + const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] }; + const empty = data.libraries.find((l) => l.name === 'empty'); + expect(empty).toBeDefined(); + expect(empty!.card_count).toBe(0); + expect(empty!.triplet_count).toBe(0); + }); +}); + // ═══════════════ t135 庫目錄移除 ═══════════════ describe('DELETE /portal/admin/libraries(t135)', () => { diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index 99954cb..130e539 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -51,6 +51,30 @@ entryRoutes.get('/libraries', async (c) => { return c.json({ success: true, libraries, count: libraries.length }); }); +// GET /entries/library-stats?owner_id=... — 每個庫的知識卡數(distinct page_name,非 block 數)。 +// t142(2026-07-29):政府驗收用——一眼看出每個庫有幾張卡(page 粒度,不是 block 粒度, +// 一張卡通常對應 3-5 個 block;不含 deprecated entries)。 +// 只計 entry_type='block' 的條目,因為 block 才對應知識卡的一個段落(page_name 標記所屬頁面)。 +entryRoutes.get('/library-stats', async (c) => { + const owner = c.req.query('owner_id') || ''; + const rows = await c.env.DB.prepare( + `SELECT + COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library, + COUNT(DISTINCT page_name) AS card_count + FROM entries + WHERE (?1 = '' OR owner_id = ?1) + AND entry_type = 'block' + AND page_name IS NOT NULL + AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated' + GROUP BY library + ORDER BY library`, + ) + .bind(owner) + .all<{ library: string; card_count: number }>(); + const stats = (rows.results ?? []).map((r) => ({ library: r.library, card_count: r.card_count })); + return c.json({ success: true, stats }); +}); + // GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search) // e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow // e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun diff --git a/kbdb/src/routes/records.ts b/kbdb/src/routes/records.ts index cc24de0..1758200 100644 --- a/kbdb/src/routes/records.ts +++ b/kbdb/src/routes/records.ts @@ -19,6 +19,38 @@ recordRoutes.post('/', async (c) => { } }); +// GET /records/triplet-stats?owner_id=... — 每個庫的三元組(關聯)數。 +// t142(2026-07-29):政府驗收——顯示每個庫整理出幾條知識關聯。 +// 計法:依 triplet 型 record 的 'library' slot 值分組計數。無 library slot 的舊三元組歸 general。 +// 使用子查詢先取 distinct triplet record IDs(針對 owner),再 LEFT JOIN library slot, +// 避免 N+1(全部一次 SQL 完成,不逐筆 getRecord)。 +recordRoutes.get('/triplet-stats', async (c) => { + const owner = c.req.query('owner_id') || ''; + // 子查詢:找到屬於這個 owner 的所有 triplet records;LEFT JOIN library slot 取庫名 + const rows = await c.env.DB.prepare( + `SELECT + COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, + COUNT(*) AS triplet_count + FROM ( + SELECT DISTINCT ev.record_id + FROM entry_values ev + JOIN templates t ON ev.template_id = t.id + JOIN entries e ON ev.entry_id = e.id + WHERE t.name = 'triplet' + AND (?1 = '' OR e.owner_id = ?1) + ) AS tr + LEFT JOIN entry_values lev + ON lev.record_id = tr.record_id AND lev.slot_name = 'library' + LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id + GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general') + ORDER BY library`, + ) + .bind(owner) + .all<{ library: string; triplet_count: number }>(); + const stats = (rows.results ?? []).map((r) => ({ library: r.library, triplet_count: r.triplet_count })); + return c.json({ success: true, stats }); +}); + // GET /records/by-template/:template — list records of a template recordRoutes.get('/by-template/:template', async (c) => { const records = await searchByTemplate(c.env.DB, c.req.param('template'), c.req.query('owner_id') || undefined); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index da48a2b..8a68536 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -305,6 +305,26 @@ KV key 設計:`{tenant}:portal:ai_config`(合併設定)`{tenant}:portal:daemon_caps`(能力回報,TTL 7 天) 測試:portal-admin.test.ts 新增 7 案(全通;全套 238 tests 229 passed,9 failed 皆 pre-existing)。 +- [x] **t142 庫目錄顯示同步張數+關聯數(2026-07-29,任務層小改)**: + 來源=leo 裁定(政府專案驗收)「雲端子庫顯示同步了幾個 wiki+幾個三元組,一眼看出這個庫真的有東西」。 + 後端(kbdb): + ① `GET /entries/library-stats?owner_id=` → `{stats: [{library, card_count}]}` + SQL:`COUNT(DISTINCT page_name)` GROUP BY library(僅 entry_type='block',排 deprecated)。 + ⚠️ 卡數=distinct page_name(一張卡 3-5 個 block),不是 COUNT(*)(防膨脹 3-5 倍)。 + 路由在 `/libraries` 之後、`/` 之前(避免被 `/:id` 吃掉)。 + ② `GET /records/triplet-stats?owner_id=` → `{stats: [{library, triplet_count}]}` + SQL:subquery DISTINCT triplet record IDs + LEFT JOIN library slot(無 slot→general), + 一次 SQL 完成,不 N+1。路由在 `/by-template/:template` 之前。 + 後端(portal.ts):`GET /portal/admin/libraries` 改並行撈三端點(Promise.all + .catch(→null)); + 已登記庫與 auto 庫各補 card_count + triplet_count(Map O(1) 查找),任一失敗不炸主流程。 + 前端(index.html renderAdminLibs):每張庫卡片補一行 + 「N 張知識卡・M 條關聯」(只顯示非零項);兩者均 0 → 顯示「還沒有內容」,不顯示「0 張」。 + 測試:`kbdb/tests/library-stats.test.ts`(14 新案:SQL 形狀/COUNT DISTINCT/entry_type filter/ + deprecated filter/COALESCE general fallback/參數傳入/回傳結構/空陣列不炸); + `portal-admin.test.ts`(t97 既有測試補三端點 mock + t142 describe 3 案: + 已登記庫帶 stats/auto 庫帶 stats/空庫 card_count=triplet_count=0)。 + vitest + node --check 待 leo 驗收環境跑(本機無 Workers runtime)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)