Files
Arcrun/mcp/tests/unit/tools/kbdb-map.test.ts
T
uncle6me-web 962d863ef7 fix(kbdb): 藏書地圖 M3 收尾——讀端自動核對重算,不再依賴 ingest 接鏈
真因(總管實測,system-dev/wiki/mistakes.md 08-08 段):design 原訂「ingest 尾端呼
POST /map/recompute」,但 repo 內查無任何呼叫點,三週沒接上,沒手動 backfill 過的租戶
(絕大多數)GET /map 恆回空;MCP 說明文字還宣稱「地圖由 ingest 尾端自動重算(M3)」——假話。

leo 否決「降級成即時聚合、不維護快取」的提案(會丟失 narrative 這類摘要本體,只算得出
count)。改法:GET /map/GET /map/:library 讀端自己核對即時三元組數,落差就地呼叫既有的
recomputeLibraryMap 補算(kbdb/src/actions/library-map.ts ensureFreshLibraryMaps)。聚合
SQL 沒有第二套、narrative/relation_profile/bridges 摘要欄位原封不動,只是觸發時機從「等
外部呼叫」改成「讀的當下順手核對」。同時解掉:全租戶自動 backfill/跟得上新資料/不依賴
跨 repo 的 ingest 接鏈。

附帶修 recomputeLibraryMap 的 narrative 欄位:沒帶值時原本會清空,改成沿用上一版(避免
自動重算把 ingest 端/人工填過的 narrative 靜默洗掉)。

修正三處說謊的說明文字(mcp/src/tools/kbdb_map.ts、console-ui console/index.html):
「地圖由 ingest 尾端自動重算(M3)」不存在,改為誠實描述讀端即時核對機制;404 語意從
「從未 recompute」改為「查無此庫」(已知但空的庫現在會自動補成 triplet_count:0 的 200,
不會落到 404)。

測試:kbdb 新增 6 案(18/18 全綠,覆蓋自動 backfill/跟得上資料/narrative 保留/
404 vs 空庫誠實分辨/owner 隔離/無 triplet template 不報錯);mcp 新增 1 案釘住舊謊言
不再出現。kbdb 125/125、mcp 69/77(同基線 8 個 oauth 既有失敗,非本次引入)全綠;
tsc 兩包乾淨(kbdb 1 個既有 auth.test.ts 錯誤與 stash 前一致,非本次引入)。

SDD:system-dev/docs/3-specs/library-map/tasks.md M3 從「07-19 誤標 」更正為實況;
design.md §3 加 2026-08-08 更正說明。未動 frontmatter status(仍 draft,D35 生命週期
鐵律留給總管/leo 裁)。

殘項:本次修改只在本機驗證(真 SQLite + 假 binding 單元測試),未部署 prod;未在真實
KBDB(如 yuga3bse 租戶)重新實測 kbdb_get_map 非空——需部署後才能貼實測輸出。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:44:55 +08:00

321 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Env } from "../../../src/types.js";
import { registerGetMap } from "../../../src/tools/kbdb_map.js";
import {
buildLibraryMapInstructions,
renderLibraryMapLines,
__resetLibraryMapInstructionsCacheForTests,
} from "../../../src/lib/library-map.js";
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
function makeServer() {
const tools = new Map<string, { description: string; handler: ToolHandler }>();
const server = {
tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
tools.set(name, { description, handler });
},
};
return { server: server as unknown as McpServer, tools };
}
// ── 假 KBDB service binding:記錄請求、回預設 response ─────────────────────────────
function makeEnv(respond: (url: URL, init?: RequestInit) => Response) {
const calls: { url: URL; init?: RequestInit }[] = [];
const env = {
KBDB: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
calls.push({ url, init });
return respond(url, init);
},
},
KBDB_INTERNAL_TOKEN: "test-token",
} as unknown as Env;
return { env, calls };
}
function parseResult(r: { content: { text: string }[] }) {
return JSON.parse(r.content[0].text) as Record<string, unknown>;
}
const KB_ROW = {
library: "kb",
narrative: "leo 的知識庫主庫",
top_entities: ["00-INDEX", "kb/00-INDEX", "Gitea"],
triplet_count: 111,
updated_at: 1784451880,
};
describe("kbdb_get_map: registration", () => {
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("{}"));
registerGetMap(server, env);
expect(tools.has("kbdb_get_map")).toBe(true);
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
});
});
describe("kbdb_get_map: 全館地圖(無參數)", () => {
it("hits GET /map via KBDB binding and returns per-library rows", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/map");
// kbdbFetch 慣例:KBDB_INTERNAL_TOKEN 注入 Authorization
expect(new Headers(calls[0].init?.headers).get("Authorization")).toBe("Bearer test-token");
const body = parseResult(res);
expect(body.ok).toBe(true);
const data = body.data as { libraries: { library: string; top_entities: string[] }[]; count: number };
expect(data.count).toBe(1);
expect(data.libraries[0].library).toBe("kb");
expect(data.libraries[0].top_entities).toEqual(["00-INDEX", "kb/00-INDEX", "Gitea"]);
});
it("owner_id is forwarded as query param", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
});
it("top_entities in JSON-string form is parsed defensively (live-observed slot shape)", async () => {
const { server, tools } = makeServer();
const row = {
...KB_ROW,
top_entities: '[{"name":"00-INDEX","degree":29},{"name":"Gitea","degree":7}]',
triplet_count: "111", // slot 底層存字串 → 要轉數字
};
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
);
registerGetMap(server, env);
const res = await tools.get("kbdb_get_map")!.handler({});
const data = parseResult(res).data as {
libraries: { top_entities: string[]; triplet_count: number }[];
};
expect(data.libraries[0].top_entities).toEqual(["00-INDEX", "Gitea"]);
expect(data.libraries[0].triplet_count).toBe(111);
});
it("空庫誠實回報+提示跑 POST /map/recompute backfill(鐵律:不假綠不 crash", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
expect(body.ok).toBe(true);
expect((body.data as { count: number }).count).toBe(0);
expect(JSON.stringify(body.hints)).toContain("POST /map/recompute");
});
// 2026-08-08:舊版說明文字宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過
// (總管實測 grep 全 repo 查無任何呼叫點),是假話。修法:GET /map 讀端本身自動核對
// 即時三元組數並補算,不靠任何外部呼叫者。這裡釘住那句謊言不會再出現在任何 hint 裡。
it("不再宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過,是假話(已修正措辭)", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const hintsText = JSON.stringify(body.hints);
expect(hintsText).not.toContain("地圖由 ingest 尾端自動重算");
expect(hintsText).not.toContain("(M3)");
expect(hintsText).not.toContain("M3");
// 誠實的新措辭:空=這個租戶真的沒資料,不是「沒人跑過 recompute」
expect(hintsText).toContain("這個租戶目前沒有任何三元組資料");
});
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
registerGetMap(server, env);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
const body = parseResult(res);
expect(body.error_code).toBe("map_fetch_failed");
expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
});
});
describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const DETAIL = {
record_id: "e_123",
library: "kb",
narrative: "leo 的知識庫主庫",
content: "kbleo 的知識庫主庫。核心:00-INDEX",
top_entities: [{ name: "00-INDEX", degree: 29 }],
relation_profile: [{ predicate: "連結至", count: 48 }],
bridges: [{ entity: "Gitea", libraries: ["notes"] }],
triplet_count: 111,
commit_hash: null,
status: "active",
updated_at: 1784451880,
};
it("hits GET /map/:library and returns full detail", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
);
registerGetMap(server, env);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(calls[0].url.pathname).toBe("/map/kb");
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
expect(map.record_id).toBe("e_123");
expect(map.relation_profile).toEqual([{ predicate: "連結至", count: 48 }]);
expect(map.bridges).toEqual([{ entity: "Gitea", libraries: ["notes"] }]);
});
it("JSON-string slot values are parsed into objects(任務規格); broken JSON → 空陣列", async () => {
const { server, tools } = makeServer();
const raw = {
...DETAIL,
top_entities: '[{"name":"00-INDEX","degree":29}]',
relation_profile: '[{"predicate":"連結至","count":48}]',
bridges: "not-json{{", // parse 失敗 → 當空陣列,不 crash
triplet_count: "111",
};
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
registerGetMap(server, env);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBeUndefined();
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
expect(map.top_entities).toEqual([{ name: "00-INDEX", degree: 29 }]);
expect(map.relation_profile).toEqual([{ predicate: "連結至", count: 48 }]);
expect(map.bridges).toEqual([]);
expect(map.triplet_count).toBe(111);
});
it("404 → map_not_found with recompute backfill hint(誠實回報)", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
);
registerGetMap(server, env);
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
expect(res.isError).toBe(true);
const body = parseResult(res);
expect(body.error_code).toBe("map_not_found");
expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
// 2026-08-08:404 現在的語意是「查無此庫」(地圖是即時核對重算的,已知庫即使空也回 200),
// 不再是舊版那句「從未 recompute」的曖昧說法。
expect(String(body.human_message)).toContain("查無庫");
expect(String(body.human_message)).toContain("從沒出現過");
});
it("binding throws → internal_error, not an unhandled crash", async () => {
const { server, tools } = makeServer();
const env = {
KBDB: {
fetch: async () => {
throw new Error("binding down");
},
},
} as unknown as Env;
registerGetMap(server, env);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("internal_error");
});
});
// ── instructions 注入(design §4:拉不到=靜默略過,絕不擋連線)────────────────────────
describe("buildLibraryMapInstructions", () => {
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
it("renders one compact line per library in the指定格式", async () => {
const { env } = makeEnv(
() =>
new Response(
JSON.stringify({
success: true,
libraries: [
KB_ROW,
{ library: "notes", narrative: "手機隨手筆記", top_entities: ["Gitea"], triplet_count: 108 },
],
count: 2,
}),
),
);
const text = await buildLibraryMapInstructions(env);
expect(text).not.toBeNull();
// design §4 格式:{library}{narrative}|核心:{top3}{triplet_count} triplets
expect(text!).toContain("kbleo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea111 triplets");
expect(text!).toContain("notes:手機隨手筆記|核心:Gitea108 triplets");
// 導引句:把地圖跟 get_map 工具接起來(design §6 retrieval 流程)
expect(text!).toContain("kbdb_get_map");
});
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
});
it("binding throws → null(靜默略過)", async () => {
const env = {
KBDB: {
fetch: async () => {
throw new Error("kbdb down");
},
},
} as unknown as Env;
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
});
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
});
it("caches within TTLsame isolate 第二次不再打 /map", async () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const first = await buildLibraryMapInstructions(env);
const second = await buildLibraryMapInstructions(env);
expect(second).toBe(first);
expect(calls).toHaveLength(1);
});
});
describe("renderLibraryMapLines", () => {
it("null narrative / string-form top_entities / string count 都容錯", () => {
const text = renderLibraryMapLines([
{
library: "kb",
narrative: null,
top_entities: '["00-INDEX"]',
triplet_count: "5",
},
]);
expect(text).toBe("- kb:(narrative 待補)|核心:00-INDEX5 triplets");
});
it("empty input → null", () => {
expect(renderLibraryMapLines([])).toBeNull();
});
});