t142: 雲端子庫顯示同步幾張卡+幾個三元組(政府專案驗收需求)

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 的懸置工作,總管收工檢查時發現並補收。
This commit is contained in:
2026-07-29 19:55:03 +08:00
parent cdca296044
commit e28e19069f
6 changed files with 211 additions and 4 deletions
+12
View File
@@ -1533,6 +1533,17 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var removeBtn = l.auto var removeBtn = l.auto
? '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove-auto" data-name="' + esc(l.name) + '">移除</button>' ? '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove-auto" data-name="' + esc(l.name) + '">移除</button>'
: '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove" data-id="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">移除</button>'; : '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove" data-id="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">移除</button>';
// 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 = '<div style="margin-top:5px;font-size:13px;color:rgba(var(--ink-rgb),.5)">' +
(parts.length ? parts.join('・') : '還沒有內容') + '</div>';
}
return '<div class="card-item">' + return '<div class="card-item">' +
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' + '<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' + '<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' +
@@ -1542,6 +1553,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
: '<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>') + : '<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>') +
removeBtn + removeBtn +
'</div>' + '</div>' +
statsHtml +
(!l.auto && l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') + (!l.auto && l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
(notWatching ? '<div style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.45)">小幫手目前沒有在同步這個資料夾</div>' : '') + (notWatching ? '<div style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.45)">小幫手目前沒有在同步這個資料夾</div>' : '') +
'</div>'; '</div>';
+30 -4
View File
@@ -1081,11 +1081,35 @@ portalRouter.get('/portal/admin/libraries', (c) =>
return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) }; return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) };
}); });
const known = new Set(out.map((l) => l.name)); const known = new Set(out.map((l) => l.name));
// 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library // t142資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
// 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
try { try {
const res = await kbdbFetch(c.env, `/entries/libraries?owner_id=${encodeURIComponent(portalTenant(c.env))}`); const tenant = portalTenant(c.env);
if (res.ok) { const ownerParam = `owner_id=${encodeURIComponent(tenant)}`;
const body = (await res.json()) as { libraries?: string[] }; 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<string, number>();
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<string, number>();
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<string, unknown>).card_count = cardMap.get(lib.name) ?? 0;
(lib as Record<string, unknown>).triplet_count = tripletMap.get(lib.name) ?? 0;
}
// 資料面自動出現的庫(蓋章即現身)
if (autoRes?.ok) {
const body = (await autoRes.json()) as { libraries?: string[] };
for (const name of body.libraries ?? []) { for (const name of body.libraries ?? []) {
const n = String(name ?? '').trim(); const n = String(name ?? '').trim();
// general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉 // general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉
@@ -1096,6 +1120,8 @@ portalRouter.get('/portal/admin/libraries', (c) =>
record_id: '', name: n, display_name: n, record_id: '', name: n, display_name: n,
description: '資料同步時自動出現(可在此補顯示名)', description: '資料同步時自動出現(可在此補顯示名)',
status: 'active', graph_source: false, auto: true, status: 'active', graph_source: false, auto: true,
card_count: cardMap.get(n) ?? 0,
triplet_count: tripletMap.get(n) ?? 0,
...(watching !== undefined ? { daemon_watching: watching } : {}), ...(watching !== undefined ? { daemon_watching: watching } : {}),
}); });
} }
@@ -381,10 +381,19 @@ describe('/portal/admin/libraries', () => {
await seedAdminSession(); await seedAdminSession();
mockGetRecord('rec_admin', adminValues()); mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', []); mockListByTemplate('portal_library', []);
// t142GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock
fetchMock fetchMock
.get(KBDB) .get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { libraries: ['kb', 'general', 'notes'] }); .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' }); const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] }; 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 + statst142', () => {
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 庫目錄移除 ═══════════════ // ═══════════════ t135 庫目錄移除 ═══════════════
describe('DELETE /portal/admin/librariest135', () => { describe('DELETE /portal/admin/librariest135', () => {
+24
View File
@@ -51,6 +51,30 @@ entryRoutes.get('/libraries', async (c) => {
return c.json({ success: true, libraries, count: libraries.length }); return c.json({ success: true, libraries, count: libraries.length });
}); });
// GET /entries/library-stats?owner_id=... — 每個庫的知識卡數(distinct page_name,非 block 數)。
// t1422026-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) // 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. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
// e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun // e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun
+32
View File
@@ -19,6 +19,38 @@ recordRoutes.post('/', async (c) => {
} }
}); });
// GET /records/triplet-stats?owner_id=... — 每個庫的三元組(關聯)數。
// t1422026-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 recordsLEFT 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 // GET /records/by-template/:template — list records of a template
recordRoutes.get('/by-template/:template', async (c) => { recordRoutes.get('/by-template/:template', async (c) => {
const records = await searchByTemplate(c.env.DB, c.req.param('template'), c.req.query('owner_id') || undefined); const records = await searchByTemplate(c.env.DB, c.req.param('template'), c.req.query('owner_id') || undefined);
@@ -305,6 +305,26 @@
KV key 設計:`{tenant}:portal:ai_config`(合併設定)`{tenant}:portal:daemon_caps`(能力回報,TTL 7 天) KV key 設計:`{tenant}:portal:ai_config`(合併設定)`{tenant}:portal:daemon_caps`(能力回報,TTL 7 天)
測試:portal-admin.test.ts 新增 7 案(全通;全套 238 tests 229 passed9 failed 皆 pre-existing)。 測試:portal-admin.test.ts 新增 7 案(全通;全套 238 tests 229 passed9 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}]}`
SQLsubquery 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_countMap 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 動工範圍,掛號) ## 第二波(不在本 SDD 動工範圍,掛號)
- MCP token 綁庫集合(design §9PR#15 擴充,只動 `mcp/` - MCP token 綁庫集合(design §9PR#15 擴充,只動 `mcp/`