diff --git a/mcp/src/lib/library-map.ts b/mcp/src/lib/library-map.ts new file mode 100644 index 0000000..113192c --- /dev/null +++ b/mcp/src/lib/library-map.ts @@ -0,0 +1,129 @@ +/** + * 藏書地圖(library-map)共用邏輯 — SDD M4(system-dev/docs/3-specs/library-map/design.md §4) + * + * 兩個 consumer 共用本檔: + * 1. kbdb_get_map MCP tool(tools/kbdb_map.ts)— 薄殼呼 GET /map//map/:library + * 2. MCP server instructions 注入(mcp-handler.ts)— 連線時把全館地圖渲染成緊湊文字嵌入 + * + * 走既有 KBDB service binding(kbdbFetch),不新增 binding、不碰 D1(薄殼鐵律)。 + * + * 鐵律(design §4):注入失敗絕不能讓 MCP 連線失敗——buildLibraryMapInstructions 任何錯誤 + * (超時/HTTP 錯/空庫/JSON 壞)一律回 null,caller 靜默略過,instructions 沒地圖照常可用。 + * + * 快取抉擇(design §4 給了「快取+TTL 或每次現拉」兩個選項,這裡選 isolate 內 TTL 快取): + * 本 MCP 用 stateless StreamableHTTP(sessionIdGenerator: undefined)——每個 HTTP request 都 + * 重建 McpServer,也就是說「每次現拉」實際上是每個 tool call 都多打一次 /map,不是每條連線一次。 + * 地圖只在 ingest recompute 後才變,所以用 module 層(isolate 內)TTL 快取:成功 5 分鐘、 + * 失敗 1 分鐘(避免 kbdb 掛掉時每個 request 都白等 timeout)。isolate 回收即自然失效,無需失效協議。 + */ + +import type { Env } from "../types.js"; +import { kbdbFetch } from "./kbdb-client.js"; + +/** 全館視圖一行(kbdb GET /map 的 libraries[] 元素;top_entities 已是 top-3 名字)。 */ +export interface LibraryMapRow { + library: string; + narrative: string | null; + top_entities: unknown; // 正常是 string[];防禦:舊部署可能回 JSON 字串形 + triplet_count: number | string; + updated_at?: number; +} + +/** + * slot 值容錯 parse:kbdb 正常已回 parsed 陣列,但 slot 底層存的是 JSON 字串 + * (live 曾觀測到字串形直出)——遇字串就 JSON.parse,parse 失敗/非陣列一律回空陣列(不 crash)。 + */ +export function parseSlotArray(raw: unknown): T[] { + if (Array.isArray(raw)) return raw as T[]; + if (typeof raw === "string") { + try { + const v = JSON.parse(raw); + return Array.isArray(v) ? (v as T[]) : []; + } catch { + return []; + } + } + return []; +} + +/** top_entities 統一成名字清單(元素可能是 "name" 字串或 {name, degree} 物件)。 */ +export function entityNames(raw: unknown, limit: number): string[] { + return parseSlotArray(raw) + .map((e) => { + if (typeof e === "string") return e; + if (e && typeof e === "object" && typeof (e as { name?: unknown }).name === "string") { + return (e as { name: string }).name; + } + return null; + }) + .filter((n): n is string => !!n) + .slice(0, limit); +} + +/** 全館地圖渲染上限:庫行數與 narrative 截斷長度(instructions 總量控制在數百 token,R3)。 */ +const MAX_LIBRARY_LINES = 30; +const MAX_NARRATIVE_CHARS = 60; + +/** 把 GET /map 的 libraries[] 渲染成緊湊文字(每庫一行,design §4 指定格式)。空清單回 null。 */ +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; + const lines = rows.slice(0, MAX_LIBRARY_LINES).map((l) => { + const narrative = (l.narrative ?? "").trim() || "(narrative 待補)"; + 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 omitted = rows.length > MAX_LIBRARY_LINES ? `\n(其餘 ${rows.length - MAX_LIBRARY_LINES} 庫略,kbdb_get_map 可看全部)` : ""; + return lines.join("\n") + omitted; +} + +/** 拉 /map 的逾時上限(ms):instructions 是加分不是依賴,不值得讓連線多等。 */ +const MAP_FETCH_TIMEOUT_MS = 1500; +/** 快取 TTL:成功 5 分鐘(地圖只在 ingest recompute 後變)、失敗/空 1 分鐘(別每 request 白等)。 */ +const CACHE_TTL_OK_MS = 5 * 60 * 1000; +const CACHE_TTL_FAIL_MS = 60 * 1000; + +let instructionsCache: { text: string | null; expiresAt: number } | null = null; + +/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */ +export function __resetLibraryMapInstructionsCacheForTests(): void { + instructionsCache = null; +} + +/** + * 組 MCP server instructions 的藏書地圖段(design §4 / §6「session 啟動 → instructions 已含 + * 全館地圖(push 零查詢)」)。任何失敗(超時/HTTP 錯/空庫/壞 JSON)→ null(caller 靜默略過)。 + */ +export async function buildLibraryMapInstructions(env: Env): Promise { + const now = Date.now(); + if (instructionsCache && instructionsCache.expiresAt > now) return instructionsCache.text; + + let text: string | null = null; + try { + const res = await Promise.race([ + kbdbFetch(env, "/map"), + new Promise((_, reject) => + setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS), + ), + ]); + if (res.ok) { + const data = (await res.json()) as { libraries?: LibraryMapRow[] }; + const body = renderLibraryMapLines(Array.isArray(data.libraries) ? data.libraries : []); + if (body) { + text = + "【藏書地圖】KBDB 全館現況(每庫一行)。查資料前先看這裡定位該進哪個庫;" + + "需要某庫細節呼叫 kbdb_get_map(library),不確定該查什麼時先呼叫 kbdb_get_map。\n" + + body; + } + } + } catch { + // 鐵律:地圖是加分不是依賴——任何錯誤都不往外拋,instructions 沒地圖照常可用。 + text = null; + } + + instructionsCache = { text, expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS) }; + return text; +} diff --git a/mcp/src/mcp-handler.ts b/mcp/src/mcp-handler.ts index d062552..ccd92d8 100644 --- a/mcp/src/mcp-handler.ts +++ b/mcp/src/mcp-handler.ts @@ -1,6 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { registerAllTools } from "./tools/registry.js"; +import { buildLibraryMapInstructions } from "./lib/library-map.js"; import { Env } from "./types.js"; export async function handleMcpRequest( @@ -9,8 +10,16 @@ export async function handleMcpRequest( orgNamespace: string, partnerToken: string, ): Promise { + // library-map SDD M4(design §4/§6):連線時把全館藏書地圖嵌進 server instructions, + // session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeout+isolate TTL 快取 + //(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。 + const mapInstructions = await buildLibraryMapInstructions(env); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - const server = new McpServer({ name: "arcrun-mcp-server", version: "1.0.0" }); + const server = new McpServer( + { name: "arcrun-mcp-server", version: "1.0.0" }, + mapInstructions ? { instructions: mapInstructions } : undefined, + ); registerAllTools(server, env, orgNamespace, partnerToken); await server.connect(transport); diff --git a/mcp/src/tools/kbdb_map.ts b/mcp/src/tools/kbdb_map.ts new file mode 100644 index 0000000..80397f0 --- /dev/null +++ b/mcp/src/tools/kbdb_map.ts @@ -0,0 +1,140 @@ +/** + * 藏書地圖 MCP 薄殼(library-map SDD M4;源頭 Arcrun#39) + * + * rule 07 §5(薄殼鐵律):地圖的聚合 SQL 全住 kbdb base(actions/library-map.ts,D6 裁定), + * MCP 只做介面轉換——經既有 KBDB service binding(kbdbFetch)打 GET /map//map/:library, + * 不碰 D1、不新增 binding。與 #68 kbdb_graph_neighbors 同族(D17 KBDB MCP 面,kbdb_* 前綴)。 + * + * 端點契約(kbdb/src/routes/map.ts,M2 已 merge): + * GET /map → { success, libraries:[{library, narrative, top_entities(名字 top3), + * triplet_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} } + * 404 → { success:false, error:'not found' }(該庫從未 recompute) + * + * slot 值防禦:top_entities/relation_profile/bridges 底層存 JSON 字串,正常 base 已 parse; + * 但仍容錯「字串形直出」(live 曾觀測)——字串就 parse、失敗當空陣列,絕不 crash(鐵律)。 + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { Env } from "../types.js"; +import { kbdbFetch } from "../lib/kbdb-client.js"; +import { errorResponse, successResponse } from "../lib/cypher-client.js"; +import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js"; + +/** 空庫/404 時的 backfill 指引(誠實回報+給下一步,鐵律:不假綠)。 */ +const RECOMPUTE_HINTS = [ + "地圖由 ingest 尾端自動重算(M3);尚未接鏈的庫要手動 backfill:對 kbdb 呼 POST /map/recompute?library=<庫名>(可帶 body {narrative, source_prefix})", + "backfill 過渡期(triplet 還沒有 library slot 值)用 source_prefix 以 source_uri 前綴歸庫,如 {\"source_prefix\":\"gitea:Leo/kb@\"}", +]; + +/** 註冊全部藏書地圖工具(library-map M4)。 */ +export function registerAllKbdbMapTools(server: McpServer, env: Env) { + registerGetMap(server, env); +} + +/** 單庫詳圖回傳形狀(GET /map/:library 的 map,slot 陣列已 parse 成物件)。 */ +interface LibraryMapDetail { + record_id?: string; + library?: string; + narrative?: string | null; + content?: string | null; + top_entities?: unknown; + relation_profile?: unknown; + bridges?: unknown; + triplet_count?: number | string; + commit_hash?: string | null; + status?: string; + updated_at?: number; +} + +/** + * kbdb_get_map — 藏書地圖。無參數=全館(每庫一行);帶 library=該庫詳圖。 + * design §6 retrieval 流程的第一站:地圖 → get_map(library) 細節 → graph/search 進庫。 + */ +export function registerGetMap(server: McpServer, env: Env) { + server.tool( + "kbdb_get_map", + "藏書地圖:KBDB 全館導覽。不帶參數=全館地圖(每庫一行:庫名+narrative+核心 top 3 entities+" + + "triplet 數),帶 library 參數=該庫詳圖(完整 top_entities/relation_profile/跨庫 bridges)。" + + "不確定該查什麼時,先呼叫此工具——先看地圖定位該進哪個庫,再用 kbdb_search(關鍵字/語義)或 " + + "kbdb_graph_neighbors(關係遍歷)進庫查細節。", + { + library: z.string().min(1).optional().describe( + "庫名(如 'kb'/'notes')。帶了回該庫詳圖;不帶回全館地圖(先看全館再挑庫)", + ), + owner_id: z.string().optional().describe("限定某資料歸屬範圍(選填,與其他 kbdb_* 工具同義)"), + }, + async ({ library, owner_id }) => { + try { + const qs = owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""; + + if (!library) { + // 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。 + const res = await kbdbFetch(env, `/map${qs}`); + if (!res.ok) { + return errorResponse( + "map_fetch_failed", + `取全館地圖失敗 HTTP ${res.status}`, + ["稍後重試", ...RECOMPUTE_HINTS], + await res.text().catch(() => ""), + ); + } + const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number }; + const libraries = (Array.isArray(data.libraries) ? data.libraries : []).map((l) => ({ + ...l, + // 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。 + top_entities: entityNames(l.top_entities, 3), + triplet_count: Number(l.triplet_count ?? 0) || 0, + })); + if (libraries.length === 0) { + // 空庫誠實回報:不是錯誤(端點正常、就是還沒有地圖),給 backfill 指引。 + return successResponse({ libraries: [], count: 0 }, [ + "全館地圖是空的:還沒有任何庫跑過 recompute", + ...RECOMPUTE_HINTS, + ]); + } + return successResponse({ libraries, count: libraries.length }, [ + "要看某庫細節:kbdb_get_map(library='庫名')", + "進庫查內容:kbdb_search(關鍵字/語義);查關係:kbdb_graph_neighbors", + ]); + } + + // 單庫詳圖:完整 slots(slot 陣列 parse 成物件再回)。 + const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`); + if (res.status === 404) { + return errorResponse( + "map_not_found", + `庫「${library}」還沒有地圖(從未 recompute,或庫名打錯)`, + ["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS], + ); + } + if (!res.ok) { + return errorResponse( + "map_fetch_failed", + `取庫「${library}」詳圖失敗 HTTP ${res.status}`, + ["稍後重試", ...RECOMPUTE_HINTS], + await res.text().catch(() => ""), + ); + } + const data = (await res.json()) as { map?: LibraryMapDetail }; + const raw = data.map ?? {}; + const map = { + ...raw, + // slot 字串容錯 parse(任務規格:JSON 字串形要 parse 成物件再回,失敗當空陣列)。 + top_entities: parseSlotArray<{ name: string; degree: number }>(raw.top_entities), + 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, + }; + return successResponse({ map }, [ + "bridges=此庫 entity 同時出現在哪些其他庫(M3 backfill 前會偏稀疏,是誠實現況不是 bug)", + "沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)", + ]); + } catch (e) { + return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); + } + }, + ); +} diff --git a/mcp/src/tools/registry.ts b/mcp/src/tools/registry.ts index 285ee5a..974c996 100644 --- a/mcp/src/tools/registry.ts +++ b/mcp/src/tools/registry.ts @@ -19,6 +19,7 @@ import { registerAllSkillExampleTools } from "./arcrun_skills_examples.js"; import { registerAllRecipeTools } from "./arcrun_recipe.js"; import { registerAllKbdbDataTools } from "./kbdb_data.js"; import { registerAllKbdbGraphTools } from "./kbdb_graph.js"; +import { registerAllKbdbMapTools } from "./kbdb_map.js"; import { registerWhoami } from "./arcrun_whoami.js"; export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) { @@ -53,6 +54,9 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri // issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點) // 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷) registerAllKbdbGraphTools(server, env, orgNamespace); + // library-map SDD M4(Arcrun#39): 藏書地圖薄殼(kbdb_get_map,調 kbdb GET /map//map/:library) + // retrieval 第一站:先看地圖定位庫,再 search/graph 進庫(design §6) + registerAllKbdbMapTools(server, env); // §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號) registerWhoami(server, env, orgNamespace); } diff --git a/mcp/tests/unit/tools/kbdb-map.test.ts b/mcp/tests/unit/tools/kbdb-map.test.ts new file mode 100644 index 0000000..3a20612 --- /dev/null +++ b/mcp/tests/unit/tools/kbdb-map.test.ts @@ -0,0 +1,297 @@ +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) => Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; +}>; + +function makeServer() { + const tools = new Map(); + 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; +} + +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"); + }); + + 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: "kb:leo 的知識庫主庫。核心: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 }).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"); + }); + + 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("kb:leo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea|111 triplets"); + expect(text!).toContain("notes:手機隨手筆記|核心:Gitea|108 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 TTL:same 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-INDEX|5 triplets"); + }); + + it("empty input → null", () => { + expect(renderLibraryMapLines([])).toBeNull(); + }); +}); diff --git a/system-dev/docs/3-specs/library-map/tasks.md b/system-dev/docs/3-specs/library-map/tasks.md index 75d1e14..ab2b612 100644 --- a/system-dev/docs/3-specs/library-map/tasks.md +++ b/system-dev/docs/3-specs/library-map/tasks.md @@ -7,7 +7,7 @@ | M1 | `library_map` Template+slots 定義(含 triplet 按庫過濾現況核實;不足則 Triplet template 加 optional library slot) | B | — | 🔨 PR 已開 | D6 零建表。核實:triplet 無 library slot → 已走預案(design §1 核實結果) | | M2 | kbdb base `POST /map/recompute?library=`+`GET /map`/`GET /map/:library`(聚合 SQL 住基本盤;交易式 supersede) | B | M1 | 🔨 PR 已開 | PR+測試(真 SQLite 驗聚合);merge 後 gated 部署(leo 閘)+逐庫 backfill recompute | | M3 | ingest 尾端接鏈:diff 涉及庫 → 逐庫呼 recompute(rag-ingest-cards v2+個人庫 ingest 同款改版) | A | M2 | ⬜ | workflow 改版走 bundle 分發 | -| M4 | MCP:instructions 注入全館地圖+`get_map` 工具 | B | M2 | ⬜ | 與 #68 同族薄殼 | +| M4 | MCP:instructions 注入全館地圖+`get_map` 工具 | B | M2 | 🔨 PR 已開 | 與 #68 同族薄殼;`kbdb_get_map`+connect 時注入(isolate TTL 快取,失敗靜默略過不擋連線);merge 後 gated redeploy arcrun-mcp(leo 閘) | | M5 | GUI 首頁:全館地圖 render(console+portal) | B | M2 | ⬜ | 取代空白搜尋框 | | M6 | D30 連動:map 層 embed+semantic 庫路由第一跳 | B | M2 | ⬜ | #58/#59/#60 家族的第一片治本 | | M7 | dogfood:leo 庫(leo21c)首個實例 backfill+驗收(requirements 驗收段全項) | A | M3-M5 | ⬜ | 過了才進 demo/客戶 | diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index 5ec3872..7352e6f 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -15,6 +15,17 @@ metadata: ## 📍 當前位置 +> **2026-07-19(#39 藏書地圖 M4,分支 `feat/mcp-library-map-inject`)**:**library-map SDD M4 PR 已開, +> 等審+gated 部署(merge 後需 leo 閘 redeploy arcrun-mcp)**。兩件(design §4): +> ① MCP tool `kbdb_get_map`(無參數=全館每庫一行;帶 library=詳圖,slot JSON 字串容錯 parse 成 +> 物件,parse 失敗當空陣列;404/空庫誠實回報+POST /map/recompute backfill 指引;description 含 +> 「不確定該查什麼時,先呼叫此工具」)——走既有 KBDB service binding(kbdbFetch),與 #68 同族薄殼。 +> ② server instructions 注入:`buildLibraryMapInstructions` 連線時拉 GET /map 渲染成每庫一行 +> (`{library}:{narrative}|核心:{top3}|{n} triplets`)嵌 instructions;**快取選型=isolate 內 +> TTL 快取(成功 5min/失敗 1min)**,因 stateless StreamableHTTP 每個 HTTP request 都重建 +> McpServer,「每次現拉」實際是每個 tool call 都多打一次;timeout 1.5s,任何失敗回 null 靜默略過 +> **絕不擋 MCP 連線**(鐵律)。測試:假 KBDB binding 17 新測(全 76/76 過)+tsc 乾淨。 +> > **2026-07-19(#39 藏書地圖 M1+M2,分支 `feat/library-map-base`)**:**library-map SDD M1+M2 PR 已開, > 等審+gated 部署(merge 後需 leo 閘 redeploy kbdb+首次 backfill 逐庫呼 recompute)**。 > M1:`library_map` template(migration 0003 seed+runtime ensure,D6 零建表);核實 triplet 按庫定位=