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:
@@ -1533,6 +1533,17 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
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" 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">' +
|
||||
'<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>' +
|
||||
@@ -1542,6 +1553,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
: '<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>') +
|
||||
removeBtn +
|
||||
'</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>' : '') +
|
||||
(notWatching ? '<div style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.45)">小幫手目前沒有在同步這個資料夾</div>' : '') +
|
||||
'</div>';
|
||||
|
||||
@@ -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<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 ?? []) {
|
||||
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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/`)
|
||||
|
||||
Reference in New Issue
Block a user