feat(mcp): library-map M4 — kbdb_get_map tool + instructions 注入全館地圖

library-map SDD M4(design §4,源頭 #39)。兩件:

1. kbdb_get_map(tools/kbdb_map.ts,與 #68 kbdb_graph_neighbors 同族薄殼):
   - 無參數=全館地圖(每庫一行:library+narrative+top 3 entities+triplet_count)
   - 帶 library=該庫詳圖;top_entities/relation_profile/bridges 若為 JSON 字串形
     容錯 parse 成物件(parse 失敗當空陣列,不 crash)
   - 404/空庫誠實回報+POST /map/recompute backfill 指引
   - 走既有 KBDB service binding(kbdbFetch),不碰 D1、不新增 binding

2. instructions 注入(lib/library-map.ts+mcp-handler.ts):
   - 連線時拉 GET /map,渲染成緊湊文字({library}:{narrative}|核心:{top3}|{n} triplets)
     嵌 server instructions(design §6:session 啟動 push 零查詢)
   - 快取選型:isolate 內 TTL 快取(成功 5min/失敗 1min)——stateless StreamableHTTP
     每個 HTTP request 重建 McpServer,「每次現拉」實際是每個 tool call 都多打一次 /map
   - timeout 1.5s;任何失敗(超時/HTTP錯/空庫/壞JSON)回 null 靜默略過,絕不擋 MCP 連線(鐵律)

測試:假 KBDB binding(比照 kbdb-graph.test.ts)17 新測,pnpm vitest run 76/76 過、tsc --noEmit 乾淨。
merge 後需 gated redeploy arcrun-mcp(leo 閘)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3
This commit is contained in:
Claude
2026-07-19 09:11:46 +00:00
parent bdfc2e3e0b
commit 343572bf5d
7 changed files with 592 additions and 2 deletions
+129
View File
@@ -0,0 +1,129 @@
/**
* 藏書地圖(library-map)共用邏輯 — SDD M4system-dev/docs/3-specs/library-map/design.md §4
*
* 兩個 consumer 共用本檔:
* 1. kbdb_get_map MCP tooltools/kbdb_map.ts)— 薄殼呼 GET /map/map/:library
* 2. MCP server instructions 注入(mcp-handler.ts)— 連線時把全館地圖渲染成緊湊文字嵌入
*
* 走既有 KBDB service bindingkbdbFetch),不新增 binding、不碰 D1(薄殼鐵律)。
*
* 鐵律(design §4):注入失敗絕不能讓 MCP 連線失敗——buildLibraryMapInstructions 任何錯誤
* (超時/HTTP 錯/空庫/JSON 壞)一律回 nullcaller 靜默略過,instructions 沒地圖照常可用。
*
* 快取抉擇(design §4 給了「快取+TTL 或每次現拉」兩個選項,這裡選 isolate 內 TTL 快取):
* 本 MCP 用 stateless StreamableHTTPsessionIdGenerator: 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 值容錯 parsekbdb 正常已回 parsed 陣列,但 slot 底層存的是 JSON 字串
* (live 曾觀測到字串形直出)——遇字串就 JSON.parse,parse 失敗/非陣列一律回空陣列(不 crash)。
*/
export function parseSlotArray<T>(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<unknown>(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 總量控制在數百 tokenR3)。 */
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)→ nullcaller 靜默略過)。
*/
export async function buildLibraryMapInstructions(env: Env): Promise<string | null> {
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<never>((_, 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;
}