fix(kbdb): 藏書地圖加 entry_count——triplet_count=0 不再被誤讀成「沒有知識」(Arcrun#87 三次收尾)

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 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-13 12:36:03 +08:00
parent 9ff933ba98
commit 614fe44812
6 changed files with 283 additions and 18 deletions
+22 -3
View File
@@ -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_countkb 以外幾乎全庫顯示「0 triplets」,
* 讀起來像「這些庫沒有知識」,於是 session 直接跳過不查——但那 7 個庫的 entries(原始 ingest
* 內容)其實都在,只是三元組萃取沒對它們跑過,kbdb_search 找得到真答案。現在 triplet_count0
* 且 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 triplets0 內容`;
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;
+37 -3
View File
@@ -5,13 +5,20 @@
* MCP 只做介面轉換——經既有 KBDB service bindingkbdbFetch)打 GET /map/map/:library
* 不碰 D1、不新增 binding。與 #68 kbdb_graph_neighbors 同族(D17 KBDB MCP 面,kbdb_* 前綴)。
*
* 端點契約(kbdb/src/routes/map.tsM2 已 merge):
* 端點契約(kbdb/src/routes/map.tsM2 已 mergeentry_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_count0 時該用 kbdb_search 查內容,不要因為 triplet_count0
* 就跳過這個庫。
*
* 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_count0 但 entry_count0 時給的提示(別讓「0 triplets」被讀成「這庫沒有知識」)。 */
function entryOnlyHint(tripletCount: number, entryCount: number, library?: string): string[] {
if (tripletCount > 0 || entryCount <= 0) return [];
const lib = library ? `${library}` : "這個庫";
return [
`${lib} triplet_count0,但有 ${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_count0 但 entry_count0entries 有、三元組
// 萃取沒跑過)——這件事只在「這種庫真的存在」時才提一次,不是每個庫都印一行(避免洗版),
// 讓讀這份地圖的 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_count0 但 entry_count0` +
"有原始內容,只是還沒萃取出三元組關係——別把 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), ["稍後重試"]);