From 614fe448124dbd25d8228e37723473f1704f3a7a Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Thu, 13 Aug 2026 12:36:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(kbdb):=20=E8=97=8F=E6=9B=B8=E5=9C=B0?= =?UTF-8?q?=E5=9C=96=E5=8A=A0=20entry=5Fcount=E2=80=94=E2=80=94triplet=5Fc?= =?UTF-8?q?ount=3D0=20=E4=B8=8D=E5=86=8D=E8=A2=AB=E8=AA=A4=E8=AE=80?= =?UTF-8?q?=E6=88=90=E3=80=8C=E6=B2=92=E6=9C=89=E7=9F=A5=E8=AD=98=E3=80=8D?= =?UTF-8?q?=EF=BC=88Arcrun#87=20=E4=B8=89=E6=AC=A1=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo21c 實測:kbdb_get_map() 9 庫裡 8 庫 triplet_count:0/top_entities:[](kb 以外全部), 一個全新 session 讀到這份地圖會合理但錯誤地判定「這些庫沒有知識」而放棄查詢——但 kbdb_search(mode=keyword) 找得到 42 筆真內容(來源 gitea:Leo/Arcrun@…/gitea:Leo/mira@…等), 其中 15 筆 entries 的 metadata_json.library 已正確標成 arcrun/mira/arcrun-harness/arcrun-rag。 查證(唯讀,未動 leo21c 任何寫入): - entries(原始 ingest 內容)與 triplet(從 entries 萃取出的三元組)是兩個不同存放處。 - kb 以外 7 庫:entries 有、library 標記正確;triplet 一筆都沒有——不是「三元組沒貼標」, 是「三元組從沒被萃取」(另一條偵察線在查斷在哪一段,跨 repo,本 PR 不處理)。 - general 庫(未標 library 的三元組兜底分類)即時計數也是 0 ⇒ 沒有任何「有三元組但沒標庫」 的候選 ⇒ Leo/Arcrun#87 先前那條批次補標通道(PR #114)在目前資料現況下會補到 0 筆, 它解的是另一個問題,不是這次「地圖看起來是空的」的真因。 本次修法(純讀端加欄位,不碰任何寫入/部署/線上資源): - kbdb/src/actions/library-map.ts:新增 liveEntryCountsByLibrary(),依 entries 自己的 metadata_json.$.library 分組即時計數(排除 entry_type='value' 儲存碎片與地圖自己的歷史 摘要 block,避免自我膨脹)。listLibraryMaps/getLibraryMapDetail/recomputeLibraryMap 的回傳都加上 entry_count,與 triplet_count 並排、互不覆蓋。 - mcp/src/lib/library-map.ts:renderLibraryMapLines(MCP 連線開場注入的那份地圖原文) triplet_count=0 但 entry_count>0 時改印「0 triplets/N 筆原始內容(尚未萃取關係, kbdb_search 查得到)」,不再只印「0 triplets」。 - mcp/src/tools/kbdb_map.ts:kbdb_get_map 工具的全館/單庫回應都帶 entry_count,並在符合 條件時附加提示,明講「triplet_count=0 不代表沒有知識」。 - console-ui/public/console/index.html:藏書地圖看板卡片同步顯示,人類看的畫面同一件事。 順手修掉一個真 SQL bug:entry_count 排除條件原寫 `NOT (entry_type='block' AND json_extract(...)='library_map')`,SQL 三值邏輯下 metadata_json 沒有 $.kind 欄位時 json_extract 回 NULL、`NULL = 'library_map'` 為 NULL (非 false),整條 WHERE 判定 NULL 而把所有列濾掉——改用 COALESCE(...,'') 修正 (新增測試以 sqlite 實跑驗證抓到並鎖住這個修法)。 測試:kbdb 21/21(新增 2 案,全套 215/215);mcp kbdb-map 33/33(新增 7 案,全套 120/120)。 cypher-executor 既有 map/portal-data 相關測試(library-map-scope-108/kbdb-map-proxy/ portal-data)103/104 綠,唯一失敗(/portal HTML 殼 404)在未動過的 main checkout 上同樣 失敗,環境既有問題、與本次改動無關。 CP:◐ 半通。程式碼在此分支,測試綠燈,未併 main、未部署 leo21c——entry_count 要讓 leo21c 的真實使用者看到,需部署 kbdb+mcp(cypher-executor 未改動,portal-data.ts 是透明轉發不需重部署)。三元組萃取斷在哪一段(真因)與 23/42 entries 未 embed (語意搜尋覆蓋率)兩件不在本 PR 範圍,分別交給另一條偵察線與 embed reconcile 管線。 Co-Authored-By: Claude Opus 5 --- console-ui/public/console/index.html | 7 +- kbdb/src/actions/library-map.ts | 76 ++++++++++++++++++--- kbdb/tests/library-map.test.ts | 57 ++++++++++++++++ mcp/src/lib/library-map.ts | 25 ++++++- mcp/src/tools/kbdb_map.ts | 40 ++++++++++- mcp/tests/unit/tools/kbdb-map.test.ts | 96 +++++++++++++++++++++++++++ 6 files changed, 283 insertions(+), 18 deletions(-) diff --git a/console-ui/public/console/index.html b/console-ui/public/console/index.html index 3be1b74..32ce446 100644 --- a/console-ui/public/console/index.html +++ b/console-ui/public/console/index.html @@ -840,7 +840,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { '
' + esc(l.library) + (S.library === l.library ? ' 搜尋中' : '') + '
' + '
' + (l.narrative ? esc(l.narrative) : '(此庫尚無 narrative——recompute 時可帶入)') + '
' + (ents && ents.length ? '
' + lmEntityLine(ents) + '
' : '') + - '
' + (Number(l.triplet_count) || 0) + ' 三元組' + + // Arcrun#87 三次收尾(2026-08-13):triplet_count=0 不等於這庫沒有知識——entries 有標庫、 + // 只是三元組萃取沒對它跑過(kb 以外幾乎全庫皆此況)。entry_count>0 時附一句,別讓看板 + // 的「0 三元組」被讀成「這庫是空的」(同 MCP 端 kbdb_map.ts 的 entryOnlyHint 同一件事)。 + '
' + (Number(l.triplet_count) || 0) + ' 三元組' + + (!(Number(l.triplet_count) || 0) && (Number(l.entry_count) || 0) > 0 + ? '・' + (Number(l.entry_count) || 0) + ' 筆原始內容(尚未萃取)' : '') + '' + '' + stamp + '
'; }).join(''); } diff --git a/kbdb/src/actions/library-map.ts b/kbdb/src/actions/library-map.ts index 2ec2b08..f233824 100644 --- a/kbdb/src/actions/library-map.ts +++ b/kbdb/src/actions/library-map.ts @@ -38,6 +38,13 @@ export interface LibraryMapRow { narrative: string | null; top_entities: string[]; // 全館視圖只回 top 3 名字(數百 token 內,design §4 MCP instructions 用) triplet_count: number; + // Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於「這庫沒有知識」—— + // entries(ingest 進來的原始卡片/skill 內容)與 triplet(從 entries 萃取出的三元組)是兩件事, + // 一個庫可能有大量 entries、但三元組萃取從沒對它跑過(實測:kb 以外 7 庫皆此況,entries 存在 + // 且 library 標記正確,triplet 一筆都沒有)。entry_count 讓讀端(AI/MCP 渲染層)分得清 + // 「真的沒東西」與「有東西但還沒被萃取成三元組」,不再把後者誤讀成前者、誤判庫是空的而放棄查詢 + // (見本檔 liveEntryCountsByLibrary 的計數口徑)。 + entry_count: number; updated_at: number; } @@ -50,6 +57,7 @@ export interface LibraryMapDetail { relation_profile: RelationStat[]; bridges: Bridge[]; triplet_count: number; + entry_count: number; // 同 LibraryMapRow.entry_count(見該欄位註解) commit_hash: string | null; status: string; updated_at: number; @@ -285,6 +293,10 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput) superseded.push(row.rid); } + // entry_count(Arcrun#87 三次收尾):recompute 只重算三元組那一半,entry_count 是即時算的 + // 另一半,兩者同樣的道理一起回傳(見 LibraryMapDetail.entry_count 欄位註解)。 + const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0; + return { map: { record_id: blockEntry.id, @@ -295,6 +307,7 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput) relation_profile: relationProfile, bridges, triplet_count: tripletCount, + entry_count: entryCount, commit_hash: input.commit_hash ?? null, status: 'active', updated_at: blockEntry.created_at, @@ -367,6 +380,34 @@ async function liveTripletCountsByLibrary( return m; } +// 這個 owner 底下、依 entries 自己的 metadata_json.$.library 分組的即時「原始內容」數 +//(Arcrun#87 三次收尾,2026-08-13)——與 liveTripletCountsByLibrary 算的是兩張完全不同的帳: +// 那個算「萃取出幾條三元組」,這個算「ingest 進來幾筆原始卡片/skill/block」。兩者天生會不一樣 +// (萃取是下游、有延遲甚至從沒對某些庫跑過),刻意分開算、分開回傳,不是同一數字的兩種寫法。 +// +// 排除口徑(都是結構性排除,不是內容語意判斷——沒有違反「base 對內容語意無知」): +// - entry_type='value':record-crud.ts 的通用機制,任何 template(含 triplet 本身)建 record +// 時每個 slot 值都會落一筆這種 entry,是儲存實作細節,不是使用者看得到的「一筆知識」。 +// - metadata.kind='library_map' 的 block:地圖自己每次 recompute 產出的摘要 block(含歷史 +// superseded 的),算進去會自我膨脹、且無限接近雞生蛋——地圖不該把自己算進地圖裡。 +async function liveEntryCountsByLibrary(db: D1Database, owner_id?: string): Promise { + const params: unknown[] = owner_id ? [owner_id] : []; + const res = await db + .prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆) + `SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library, + COUNT(*) AS n + FROM entries + WHERE ${owner_id ? 'owner_id = ? AND ' : ''}entry_type != 'value' + AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map') + GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`, + ) + .bind(...params) + .all<{ library: string; n: number }>(); + const m: LibraryCountMap = new Map(); + for (const r of res.results ?? []) m.set(r.library, r.n); + return m; +} + // 「已知庫名」集合:即使目前三元組數是 0,只要蓋過章(entries metadata.library,t52 慣例)或 // 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的 // 「這庫是空的」(回 200+triplet_count:0)vs「查無此庫」(回 404)。kbdb base 對 portal_library @@ -453,20 +494,26 @@ interface MapPivotRow { ts: number; } -// 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count),MCP instructions -// 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ 誠實回空清單。 +// 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count+entry_count),MCP +// instructions 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ +// 誠實回空清單。entry_count 與 triplet_count 並排回傳(Arcrun#87 三次收尾):一個查「原始內容 +// 有沒有」、一個查「萃取出幾條三元組」,兩者不同源、允許不一致(見 liveEntryCountsByLibrary +// 註解),讀端不該把其中一個的 0 當成另一個的答案。 export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise { const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME); if (!tpl) return []; const params: unknown[] = owner_id ? [tpl.id, owner_id] : [tpl.id]; - const res = await db - .prepare( - `WITH m AS (${mapPivotSql(!!owner_id)}) - SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL - ORDER BY m.ts DESC`, - ) - .bind(...params) - .all(); + const [res, entryCounts] = await Promise.all([ + db + .prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解 + `WITH m AS (${mapPivotSql(!!owner_id)}) + SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL + ORDER BY m.ts DESC`, + ) + .bind(...params) + .all(), + liveEntryCountsByLibrary(db, owner_id), + ]); // 每庫只留最新 active(supersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。 const byLib = new Map(); for (const r of res.results ?? []) { @@ -476,6 +523,7 @@ export async function listLibraryMaps(db: D1Database, owner_id?: string): Promis narrative: r.narrative || null, top_entities: parseJsonArray(r.top_entities).slice(0, 3).map((t) => t.name), triplet_count: Number(r.triplet_count ?? 0) || 0, + entry_count: entryCounts.get(r.library) ?? 0, updated_at: r.ts, }); } @@ -501,7 +549,12 @@ export async function getLibraryMapDetail( .first(); if (!row) return null; // record_id=map block entry id(recompute 寫入時綁定);entry 若被外力刪除,content 誠實回 null。 - const blockEntry = await getEntry(db, row.rid); + // entry_count 與全館視圖(listLibraryMaps)同一套計法(liveEntryCountsByLibrary),單庫詳圖 + // 只取自己那一庫的數字——Arcrun#87 三次收尾,理由見 LibraryMapDetail.entry_count 欄位註解。 + const [blockEntry, entryCounts] = await Promise.all([ + getEntry(db, row.rid), + liveEntryCountsByLibrary(db, owner_id), + ]); return { record_id: row.rid, library, @@ -511,6 +564,7 @@ export async function getLibraryMapDetail( relation_profile: parseJsonArray(row.relation_profile), bridges: parseJsonArray(row.bridges), triplet_count: Number(row.triplet_count ?? 0) || 0, + entry_count: entryCounts.get(library) ?? 0, commit_hash: row.commit_hash || null, status: row.status ?? 'active', updated_at: row.ts, diff --git a/kbdb/tests/library-map.test.ts b/kbdb/tests/library-map.test.ts index 149e2e2..009941e 100644 --- a/kbdb/tests/library-map.test.ts +++ b/kbdb/tests/library-map.test.ts @@ -361,6 +361,63 @@ describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動 expect(unknown.status).toBe(404); // 真的從沒出現過的名字才 404 }); + it('entry_count 讓「有原始內容但三元組從沒萃取過」與「真的什麼都沒有」分得清(Arcrun#87 三次收尾)', async () => { + // 情境沿用上一案的 'hr' 庫(entries 蓋過章、triplet_count:0),這正是 leo21c 實測到的真實 + // 現況(kb 以外 7 庫皆此況):地圖過去只回 triplet_count,AI 讀到 0 就誤判「這庫沒有知識」, + // 但實際上 kbdb_search 找得到內容——因為 entries 一直都在,只是沒被萃取成三元組。 + const db = makeSqliteD1(); + await seedTripletTemplate(db); + await ensureTripletLibrarySlot(db, 'triplet'); + await createEntry(db, { + content: '人資資料 A', + entry_type: 'block', + owner_id: 'leo', + metadata_json: JSON.stringify({ library: 'hr' }), + }); + await createEntry(db, { + content: '人資資料 B', + entry_type: 'block', + owner_id: 'leo', + metadata_json: JSON.stringify({ library: 'hr' }), + }); + const { app, env } = makeApp(db); + + // 單庫詳圖:triplet_count 仍是 0(沒騙這件事),但 entry_count 誠實回 2。 + const detailRes = await app.request('/map/hr?owner_id=leo', {}, env); + const detailBody = (await detailRes.json()) as { map: { triplet_count: number; entry_count: number } }; + expect(detailBody.map.triplet_count).toBe(0); + expect(detailBody.map.entry_count).toBe(2); + + // 全館視圖:同一個庫、同一組數字,兩個 consumer(GET /map、GET /map/:library)不能對不上。 + const listRes = await app.request('/map?owner_id=leo', {}, env); + const listBody = (await listRes.json()) as { libraries: { library: string; triplet_count: number; entry_count: number }[] }; + const hr = listBody.libraries.find((l) => l.library === 'hr'); + expect(hr).toBeDefined(); + expect(hr!.triplet_count).toBe(0); + expect(hr!.entry_count).toBe(2); + }); + + it('entry_count 的計數口徑排除 value 型 entries 與地圖自己的歷史摘要 block(不自我膨脹)', async () => { + // record-crud 建 triplet record 時,每個 slot 值都會落一筆 entry_type='value' 的 entry + // (儲存實作細節,不是「一筆知識」);地圖 recompute 也會建 entry_type='block' 且 + // metadata.kind='library_map' 的摘要 entry——兩者都不該被算進 entry_count,否則地圖會把 + // 自己的內部管線雜訊當成「使用者知識」回報,數字沒有意義。 + const db = makeSqliteD1(); + await seedTripletTemplate(db); + await ensureTripletLibrarySlot(db, 'triplet'); + // 建一條 kb 三元組 → 連帶產生數筆 entry_type='value' 的 entries(不該被算進 entry_count)。 + await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); + // 觸發一次 recompute → 產生一筆 entry_type='block'/metadata.kind='library_map' 的摘要 entry + // (同樣不該被算進 entry_count)。 + await recomputeLibraryMap(db, { library: 'kb' }); + + const detail = await getLibraryMapDetail(db, 'kb'); + // 這個情境下 kb 沒有任何「真的 ingest 進來的原始內容」entry(只有 value 碎片與地圖自己的摘要), + // entry_count 應誠實回 0——不能因為底層 entries 表其實有好幾筆就報出一個誤導的非零數字。 + expect(detail!.entry_count).toBe(0); + expect(detail!.triplet_count).toBe(1); // 對照:triplet_count 不受這個排除規則影響,維持原樣。 + }); + it('owner 隔離:即時新鮮度層不會把別的 owner 的三元組算進來', async () => { const db = makeSqliteD1(); await seedTripletTemplate(db); diff --git a/mcp/src/lib/library-map.ts b/mcp/src/lib/library-map.ts index 6003f7a..1a6b788 100644 --- a/mcp/src/lib/library-map.ts +++ b/mcp/src/lib/library-map.ts @@ -27,6 +27,10 @@ export interface LibraryMapRow { narrative: string | null; top_entities: unknown; // 正常是 string[];防禦:舊部署可能回 JSON 字串形 triplet_count: number | string; + // Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於這庫沒有知識—— + // 可能只是三元組萃取從沒對它跑過,但 entries(原始 ingest 內容)還在,kbdb_search 找得到。 + // 選填是因為舊部署(未帶這次修法)的 kbdb base 回應裡不會有這個欄位,容錯當 0。 + entry_count?: number | string; updated_at?: number; } @@ -65,7 +69,15 @@ export function entityNames(raw: unknown, limit: number): string[] { const MAX_LIBRARY_LINES = 30; const MAX_NARRATIVE_CHARS = 60; -/** 把 GET /map 的 libraries[] 渲染成緊湊文字(每庫一行,design §4 指定格式)。空清單回 null。 */ +/** + * 把 GET /map 的 libraries[] 渲染成緊湊文字(每庫一行,design §4 指定格式)。空清單回 null。 + * + * Arcrun#87 三次收尾(2026-08-13):這是「開場那份地圖」的真身——一個全新 session 連上 MCP, + * 這段文字就是它對藏書地圖的第一印象。過去只印 triplet_count,kb 以外幾乎全庫顯示「0 triplets」, + * 讀起來像「這些庫沒有知識」,於是 session 直接跳過不查——但那 7 個庫的 entries(原始 ingest + * 內容)其實都在,只是三元組萃取沒對它們跑過,kbdb_search 找得到真答案。現在 triplet_count=0 + * 且 entry_count>0 時明講「有內容、只是還沒萃取」,別再讓這句話變成瞎猜的理由。 + */ export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null { const rows = libraries.filter((l) => l && typeof l.library === "string" && l.library); if (rows.length === 0) return null; @@ -74,8 +86,15 @@ export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null const clipped = narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}…` : narrative; const core = entityNames(l.top_entities, 3); - const count = Number(l.triplet_count ?? 0) || 0; - return `- ${l.library}:${clipped}|核心:${core.length ? core.join("、") : "(尚無)"}|${count} triplets`; + const tripletCount = Number(l.triplet_count ?? 0) || 0; + const entryCount = Number(l.entry_count ?? 0) || 0; + const countPart = + tripletCount > 0 + ? `${tripletCount} triplets` + : entryCount > 0 + ? `0 triplets/${entryCount} 筆原始內容(尚未萃取關係,kbdb_search 查得到)` + : `0 triplets/0 內容`; + return `- ${l.library}:${clipped}|核心:${core.length ? core.join("、") : "(尚無)"}|${countPart}`; }); const omitted = rows.length > MAX_LIBRARY_LINES ? `\n(其餘 ${rows.length - MAX_LIBRARY_LINES} 庫略,kbdb_get_map 可看全部)` : ""; return lines.join("\n") + omitted; diff --git a/mcp/src/tools/kbdb_map.ts b/mcp/src/tools/kbdb_map.ts index f5922bf..0e8b891 100644 --- a/mcp/src/tools/kbdb_map.ts +++ b/mcp/src/tools/kbdb_map.ts @@ -5,13 +5,20 @@ * MCP 只做介面轉換——經既有 KBDB service binding(kbdbFetch)打 GET /map//map/:library, * 不碰 D1、不新增 binding。與 #68 kbdb_graph_neighbors 同族(D17 KBDB MCP 面,kbdb_* 前綴)。 * - * 端點契約(kbdb/src/routes/map.ts,M2 已 merge): + * 端點契約(kbdb/src/routes/map.ts,M2 已 merge;entry_count 為 Arcrun#87 三次收尾新增): * GET /map → { success, libraries:[{library, narrative, top_entities(名字 top3), - * triplet_count, updated_at}], count } + * triplet_count, entry_count, updated_at}], count } * GET /map/:library → { success, map:{record_id, library, narrative, content, top_entities, - * relation_profile, bridges, triplet_count, commit_hash, status, updated_at} } + * relation_profile, bridges, triplet_count, entry_count, commit_hash, + * status, updated_at} } * 404 → { success:false, error:'not found' }(該庫從未 recompute) * + * entry_count vs triplet_count(讀這份地圖前務必分清楚,否則會誤判某庫「沒有知識」): + * triplet_count=萃取出的三元組數;entry_count=ingest 進來的原始內容(卡片/skill/block)數。 + * 兩者不同源,一個 0 一個非 0 是正常狀態——triplet_count=0 只代表「還沒萃取關係」, + * 不代表「沒有資料」。entry_count>0 時該用 kbdb_search 查內容,不要因為 triplet_count=0 + * 就跳過這個庫。 + * * slot 值防禦:top_entities/relation_profile/bridges 底層存 JSON 字串,正常 base 已 parse; * 但仍容錯「字串形直出」(live 曾觀測)——字串就 parse、失敗當空陣列,絕不 crash(鐵律)。 */ @@ -59,11 +66,23 @@ interface LibraryMapDetail { relation_profile?: unknown; bridges?: unknown; triplet_count?: number | string; + // Arcrun#87 三次收尾:同 lib/library-map.ts LibraryMapRow.entry_count 的欄位(見該檔註解)。 + entry_count?: number | string; commit_hash?: string | null; status?: string; updated_at?: number; } +/** triplet_count=0 但 entry_count>0 時給的提示(別讓「0 triplets」被讀成「這庫沒有知識」)。 */ +function entryOnlyHint(tripletCount: number, entryCount: number, library?: string): string[] { + if (tripletCount > 0 || entryCount <= 0) return []; + const lib = library ? `「${library}」` : "這個庫"; + return [ + `${lib} triplet_count=0,但有 ${entryCount} 筆原始內容(entries)——三元組萃取還沒對它跑過,` + + "不代表沒有知識。用 kbdb_search(關鍵字或語義)直接查得到內容。", + ]; +} + /** * kbdb_get_map — 藏書地圖。無參數=全館(每庫一行);帶 library=該庫詳圖。 * design §6 retrieval 流程的第一站:地圖 → get_map(library) 細節 → graph/search 進庫。 @@ -113,6 +132,7 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI // 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。 top_entities: entityNames(l.top_entities, 3), triplet_count: Number(l.triplet_count ?? 0) || 0, + entry_count: Number(l.entry_count ?? 0) || 0, })); if (libraries.length === 0) { // 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS @@ -128,9 +148,21 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI ...RECOMPUTE_HINTS, ]); } + // Arcrun#87 三次收尾:某些庫 triplet_count=0 但 entry_count>0(entries 有、三元組 + // 萃取沒跑過)——這件事只在「這種庫真的存在」時才提一次,不是每個庫都印一行(避免洗版), + // 讓讀這份地圖的 session 知道「觸目所及的 0」不能直接當成「沒有知識」。 + const entryOnlyLibs = libraries.filter( + (l) => (l.triplet_count ?? 0) === 0 && (l.entry_count ?? 0) > 0, + ); return successResponse({ libraries, count: libraries.length }, [ "要看某庫細節:kbdb_get_map(library='庫名')", "進庫查內容:kbdb_search(關鍵字/語義);查關係:kbdb_graph_neighbors", + ...(entryOnlyLibs.length > 0 + ? [ + `${entryOnlyLibs.map((l) => l.library).join("、")} 這幾庫 triplet_count=0 但 entry_count>0:` + + "有原始內容,只是還沒萃取出三元組關係——別把 0 triplets 讀成「沒有知識」,直接 kbdb_search 進去查。", + ] + : []), ]); } @@ -167,10 +199,12 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI relation_profile: parseSlotArray<{ predicate: string; count: number }>(raw.relation_profile), bridges: parseSlotArray<{ entity: string; libraries: string[] }>(raw.bridges), triplet_count: Number(raw.triplet_count ?? 0) || 0, + entry_count: Number(raw.entry_count ?? 0) || 0, }; return successResponse({ map }, [ "bridges=此庫 entity 同時出現在哪些其他庫(只有兩側三元組都標了 library 值才抓得到,舊資料若沒標會偏稀疏,是誠實現況不是 bug)", "沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)", + ...entryOnlyHint(map.triplet_count, map.entry_count, library), ]); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); diff --git a/mcp/tests/unit/tools/kbdb-map.test.ts b/mcp/tests/unit/tools/kbdb-map.test.ts index a9faf0c..ea2925f 100644 --- a/mcp/tests/unit/tools/kbdb-map.test.ts +++ b/mcp/tests/unit/tools/kbdb-map.test.ts @@ -121,6 +121,46 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => { expect(calls[0].url.searchParams.get("owner_id")).toBe("leo"); }); + it("Arcrun#87 三次收尾:triplet_count=0 但 entry_count>0 的庫附上「別當成沒有知識」的提示", async () => { + // leo21c 實測現況:kb 以外幾乎每庫都長這樣——entries 有(且 library 標對),三元組萃取沒跑過。 + const emptyTripletRow = { + library: "arcrun", + narrative: null, + top_entities: [], + triplet_count: 0, + entry_count: 15, + updated_at: 1786330534, + }; + const { server, tools } = makeServer(); + const { env } = makeEnv( + () => new Response(JSON.stringify({ success: true, libraries: [emptyTripletRow], count: 1 })), + ); + registerGetMap(server, env, SERVICE); + const res = await tools.get("kbdb_get_map")!.handler({}); + const body = parseResult(res); + const data = body.data as { libraries: { library: string; triplet_count: number; entry_count: number }[] }; + expect(data.libraries[0].entry_count).toBe(15); + expect(data.libraries[0].triplet_count).toBe(0); + // 提示要點名該庫、講清楚「有內容只是沒萃取」,別讓 AI 讀到 0 triplets 就跳過這個庫。 + const hints = (body.hints as string[]).join(" "); + expect(hints).toContain("arcrun"); + expect(hints).toContain("entry_count"); + expect(hints).toContain("kbdb_search"); + }); + + it("entry_count 缺席(舊部署未帶此欄位)→ 容錯當 0,不 crash、不誤發提示", async () => { + const legacyRow = { ...KB_ROW }; // KB_ROW 本身沒有 entry_count 欄位(模擬舊部署回應) + const { server, tools } = makeServer(); + const { env } = makeEnv( + () => new Response(JSON.stringify({ success: true, libraries: [legacyRow], count: 1 })), + ); + registerGetMap(server, env, SERVICE); + const res = await tools.get("kbdb_get_map")!.handler({}); + const body = parseResult(res); + const data = body.data as { libraries: { entry_count: number }[] }; + expect(data.libraries[0].entry_count).toBe(0); + }); + it("top_entities in JSON-string form is parsed defensively (live-observed slot shape)", async () => { const { server, tools } = makeServer(); const row = { @@ -213,6 +253,39 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => { expect(map.bridges).toEqual([{ entity: "Gitea", libraries: ["notes"] }]); }); + it("Arcrun#87 三次收尾:單庫詳圖 triplet_count=0、entry_count>0 → 附「別當空庫」提示", async () => { + const emptyTripletDetail = { + ...DETAIL, + library: "arcrun", + triplet_count: 0, + entry_count: 15, + top_entities: [], + relation_profile: [], + bridges: [], + }; + const { server, tools } = makeServer(); + const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: emptyTripletDetail }))); + registerGetMap(server, env, SERVICE); + const res = await tools.get("kbdb_get_map")!.handler({ library: "arcrun" }); + const body = parseResult(res); + const map = (body.data as { map: { entry_count: number; triplet_count: number } }).map; + expect(map.triplet_count).toBe(0); + expect(map.entry_count).toBe(15); + const hints = (body.hints as string[]).join(" "); + expect(hints).toContain("arcrun"); + expect(hints).toContain("kbdb_search"); + }); + + it("triplet_count>0 的庫不附「別當空庫」提示(不需要時別洗版)", async () => { + const { server, tools } = makeServer(); + const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: DETAIL }))); // DETAIL.triplet_count=111 + registerGetMap(server, env, SERVICE); + const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" }); + const body = parseResult(res); + const hints = (body.hints as string[]).join(" "); + expect(hints).not.toContain("entry_count"); + }); + it("JSON-string slot values are parsed into objects(任務規格); broken JSON → 空陣列", async () => { const { server, tools } = makeServer(); const raw = { @@ -343,6 +416,29 @@ describe("renderLibraryMapLines", () => { it("empty input → null", () => { expect(renderLibraryMapLines([])).toBeNull(); }); + + it("Arcrun#87 三次收尾:triplet_count=0 但 entry_count>0 → 開場那行不再讀成「這庫沒有知識」", () => { + // 這是 leo21c 的實際現況(kb 以外 7 庫皆此況)——這段渲染出的文字是全新 session 連上 MCP + // 第一眼看到的地圖,過去只印「0 triplets」,讀起來像空庫,session 因此跳過不查。 + const text = renderLibraryMapLines([ + { library: "arcrun", narrative: null, top_entities: [], triplet_count: 0, entry_count: 15 }, + ]); + expect(text).toBe("- arcrun:(narrative 待補)|核心:(尚無)|0 triplets/15 筆原始內容(尚未萃取關係,kbdb_search 查得到)"); + }); + + it("triplet_count=0 且 entry_count=0(真的沒有任何內容)→ 誠實講兩個都是 0", () => { + const text = renderLibraryMapLines([ + { library: "empty-lib", narrative: null, top_entities: [], triplet_count: 0, entry_count: 0 }, + ]); + expect(text).toBe("- empty-lib:(narrative 待補)|核心:(尚無)|0 triplets/0 內容"); + }); + + it("entry_count 缺席(舊部署未帶欄位)→ 容錯當 0,維持既有 triplet-only 措辭不 crash", () => { + const text = renderLibraryMapLines([ + { library: "kb", narrative: "摘要", top_entities: ["A"], triplet_count: 5 }, + ]); + expect(text).toBe("- kb:摘要|核心:A|5 triplets"); + }); }); // ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────