/** * 藏書地圖(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"; import { portalFetch, type KnowledgeIdentity } from "./portal-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; /** * 快取以「身分」分格(2026-08-12)。 * * 為什麼不能共用一格:地圖本身就是情報(哪些庫存在、各有多少關聯、核心 entity 是誰)。 * 以帳密連線時只該看到自己有權限的庫;若跟服務級連線共用同一格快取,先連上的那個人 * 會把自己的視野留給下一個人——那是跨帳號外洩,不是效能問題。 */ const instructionsCache = new Map(); /** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */ export function __resetLibraryMapInstructionsCacheForTests(): void { instructionsCache.clear(); } /** * 組 MCP server instructions 的藏書地圖段(design §4 / §6「session 啟動 → instructions 已含 * 全館地圖(push 零查詢)」)。任何失敗(超時/HTTP 錯/空庫/壞 JSON)→ null(caller 靜默略過)。 * * 以帳密連線(identity.kind === 'portal')時走 cypher `/portal/data/map`——只拿得到這個 * 帳號有權限的庫;服務級憑據維持既有 KBDB `/map` 直連。舊 token(stale)不給地圖。 */ export async function buildLibraryMapInstructions( env: Env, identity: KnowledgeIdentity, ): Promise { if (identity.kind === "stale") return null; // 快取 key:portal 用 session(=這個人這次登入),service 用固定字串。 // session token 只當 Map 的 key 活在 isolate 記憶體內,不落地、不寫 log。 const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service"; const now = Date.now(); const hit = instructionsCache.get(cacheKey); if (hit && hit.expiresAt > now) return hit.text; let text: string | null = null; try { const res = await Promise.race([ identity.kind === "portal" ? portalFetch(env, identity.portal.session, "/portal/data/map") : 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.set(cacheKey, { text, expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS), }); // isolate 內的快取,不做失效協議;但別讓不同帳號的格子無上限長大(isolate 可活很久)。 if (instructionsCache.size > 64) { for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k); } return text; }