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:
@@ -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<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.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<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),
|
||||
]);
|
||||
// 每庫只留最新 active(supersede 失敗殘留多個 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_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<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,
|
||||
|
||||
Reference in New Issue
Block a user