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:
@@ -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:地圖也要跟著登入者的權限走 ────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user