Files
Arcrun/mcp/src/tools/kbdb_map.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

156 lines
8.5 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.
/**
* 藏書地圖 MCP 薄殼(library-map SDD M4;源頭 Arcrun#39
*
* rule 07 §5(薄殼鐵律):地圖的聚合 SQL 全住 kbdb baseactions/library-map.tsD6 裁定),
* MCP 只做介面轉換——經既有 KBDB service bindingkbdbFetch)打 GET /map/map/:library
* 不碰 D1、不新增 binding。與 #68 kbdb_graph_neighbors 同族(D17 KBDB MCP 面,kbdb_* 前綴)。
*
* 端點契約(kbdb/src/routes/map.tsM2 已 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 時的指引(誠實回報+給下一步,鐵律:不假綠)。
*
* 2026-08-08 修正:舊版寫「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過(總管實測
* grep 全 repo 查無任何呼叫點),是假話。真正的機制:kbdb `GET /map``GET /map/:library`
* 每次查詢都會自動核對即時三元組數,落差就地重算(`kbdb/src/actions/library-map.ts`
* `ensureFreshLibraryMaps`)——資料進來後下一次查詢就會反映,不需要任何人記得呼叫任何端點。
* `POST /map/recompute` 仍然存在,但只在極少數情況才需要手動打:舊三元組沒有 `library` slot
* 值、只能靠 `source_uri` 前綴回填時(`source_prefix` 參數)。
*/
const RECOMPUTE_HINTS = [
"地圖每次查詢都會自動核對即時三元組數並重算過期的庫,不必手動處理",
"少見情況(舊三元組沒有 library 標記)才需要手動:POST /map/recompute?library=<庫名>(可帶 body {narrative, source_prefix}",
];
/** 註冊全部藏書地圖工具(library-map M4)。 */
export function registerAllKbdbMapTools(server: McpServer, env: Env) {
registerGetMap(server, env);
}
/** 單庫詳圖回傳形狀(GET /map/:library 的 mapslot 陣列已 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) {
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_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) {
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
// 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
// 不再是「沒人跑過 recompute」那種曖昧狀態。
return successResponse({ libraries: [], count: 0 }, [
"全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
...RECOMPUTE_HINTS,
]);
}
return successResponse({ libraries, count: libraries.length }, [
"要看某庫細節:kbdb_get_map(library='庫名')",
"進庫查內容:kbdb_search(關鍵字/語義);查關係:kbdb_graph_neighbors",
]);
}
// 單庫詳圖:完整 slotsslot 陣列 parse 成物件再回)。
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
if (res.status === 404) {
// 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
// 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
// 代表這個名字在這個租戶的資料裡從沒出現過——不是「這庫是空的」,是根本沒有這個庫
// (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
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 同時出現在哪些其他庫(只有兩側三元組都標了 library 值才抓得到,舊資料若沒標會偏稀疏,是誠實現況不是 bug)",
"沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)",
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
},
);
}