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
+65 -11
View File
@@ -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 不等於「這庫沒有知識」——
// entriesingest 進來的原始卡片/skill 內容)與 triplet(從 entries 萃取出的三元組)是兩件事,
// 一個庫可能有大量 entries、但三元組萃取從沒對它跑過(實測:kb 以外 7 庫皆此況,entries 存在
// 且 library 標記正確,triplet 一筆都沒有)。entry_count 讓讀端(AIMCP 渲染層)分得清
// 「真的沒東西」與「有東西但還沒被萃取成三元組」,不再把後者誤讀成前者、誤判庫是空的而放棄查詢
// (見本檔 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_countArcrun#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<LibraryCountMap> {
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.libraryt52 慣例)或
// 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的
// 「這庫是空的」(回 200triplet_count:0vs「查無此庫」(回 404)。kbdb base 對 portal_library
@@ -453,20 +494,26 @@ interface MapPivotRow {
ts: number;
}
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count),MCP instructions
// 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ 誠實回空清單。
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_countentry_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<LibraryMapRow[]> {
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<MapPivotRow>();
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<MapPivotRow>(),
liveEntryCountsByLibrary(db, owner_id),
]);
// 每庫只留最新 activesupersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。
const byLib = new Map<string, LibraryMapRow>();
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<TopEntity>(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<MapPivotRow>();
if (!row) return null;
// record_idmap block entry idrecompute 寫入時綁定);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<RelationStat>(row.relation_profile),
bridges: parseJsonArray<Bridge>(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,