From 15fe012006b4ab424e10375a1cefff323fc75a48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 08:40:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(kbdb):=20=E8=97=8F=E6=9B=B8=E5=9C=B0?= =?UTF-8?q?=E5=9C=96=20M1+M2=20=E2=80=94=20library=5Fmap=20template?= =?UTF-8?q?=EF=BC=8B/map=20=E7=AB=AF=E9=BB=9E=EF=BC=88=E8=81=9A=E5=90=88?= =?UTF-8?q?=20SQL=20=E4=BD=8F=E5=9F=BA=E6=9C=AC=E7=9B=A4=EF=BC=89(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1:library_map template(migration 0003 seed+runtime ensure,D6 零建表零 ALTER); 核實 triplet 按庫定位=不足(prod triplet template 無 library slot、entries metadata.library 未標記)→ 走 SDD 預案:recompute 冪等補 optional library slot (改 template 不動表),slot 值由 ingest 端(M3)補寫,核實結果記入 design §1。 M2:kbdb base 三端點(design §2 歸屬裁定:聚合 SQL 只准住基本盤): - POST /map/recompute?library=X:degree top-N/predicate 分布/跨庫 bridges/ triplet_count 一段 SQL 聚合;narrative 本輪收 body 傳入(wiki 抽取屬 M3); 寫入順序安全(先建新 active map block 再標舊 superseded,讀端取最新 active 自癒); 過渡 source_prefix fallback 讓無 library 值的舊 triplet 靠 source_uri 前綴歸庫。 - GET /map:全館地圖每庫一行(library+narrative+top 3 entities+triplet_count), MCP instructions/GUI 共用(數百 token 內)。 - GET /map/:library:該庫詳圖(完整 slots+可嵌人話 content,design §5 M6 直用)。 測試:node:sqlite(零新依賴)當真 SQLite 實跑 migrations+聚合 SQL, Hono route 行為同款覆蓋;45/45 過、tsc --noEmit 乾淨。 關聯 #39 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3 --- kbdb/migrations/0003_library_map.sql | 15 + kbdb/src/actions/library-map.ts | 377 ++++++++++++++++++ kbdb/src/index.ts | 4 + kbdb/src/routes/map.ts | 48 +++ kbdb/tests/library-map.test.ts | 252 ++++++++++++ kbdb/tests/node-builtins.d.ts | 21 + system-dev/docs/3-specs/library-map/design.md | 2 + system-dev/docs/3-specs/library-map/tasks.md | 4 +- system-dev/wiki/status.md | 10 + 9 files changed, 731 insertions(+), 2 deletions(-) create mode 100644 kbdb/migrations/0003_library_map.sql create mode 100644 kbdb/src/actions/library-map.ts create mode 100644 kbdb/src/routes/map.ts create mode 100644 kbdb/tests/library-map.test.ts create mode 100644 kbdb/tests/node-builtins.d.ts diff --git a/kbdb/migrations/0003_library_map.sql b/kbdb/migrations/0003_library_map.sql new file mode 100644 index 0000000..3f6f695 --- /dev/null +++ b/kbdb/migrations/0003_library_map.sql @@ -0,0 +1,15 @@ +-- library-map(藏書地圖)M1 — `library_map` template seed +-- SDD: system-dev/docs/3-specs/library-map/design.md §1(源頭 Arcrun#39 spec §3) +-- +-- D6 鐵律:零建表、零 ALTER——地圖=templates 表的一列 seed row(與 0001 的 recipe_stat 同模式), +-- 每庫一個 map block(record)+slots,全部住既有三表。 +-- 冪等(INSERT OR IGNORE);runtime 端 actions/library-map.ts 的 ensureLibraryMapTemplate 是同一 +-- 定義的第二道保險(已部署未跑 migration 的 D1 上,首次 recompute 會自建),兩邊 id/slots 必須一致。 +INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by) +VALUES ( + 'tpl-library-map', + 'library_map', + 'per-library map block(藏書地圖:graph 機械導出,零 LLM 生成;Arcrun#39)', + '["library","narrative","top_entities","relation_profile","bridges","triplet_count","commit_hash","status"]', + 'system' +); diff --git a/kbdb/src/actions/library-map.ts b/kbdb/src/actions/library-map.ts new file mode 100644 index 0000000..776344e --- /dev/null +++ b/kbdb/src/actions/library-map.ts @@ -0,0 +1,377 @@ +// library-map(藏書地圖)— 聚合 SQL 的家(SDD system-dev/docs/3-specs/library-map/design.md §2)。 +// D6 鐵律推論:degree 排序/predicate 統計/跨庫 join 是聚合 SQL,插件與 workflow 全程禁 SQL, +// 所以重算只能住 kbdb base 本體(本檔)。三表不變量不破:地圖=library_map template 的 record +//(每庫一個 map block)+slots,零建表零 ALTER。 +// +// triplet 按庫定位現況(2026-07-19 對 prod 核實,design §1 的「先核實」): +// - triplet template(prod 名 'triplet',kbdb-graph 建)有 source_uri slot、無 library slot; +// - entries 的 metadata.library 機制在(portal-auth P1)但既有資料未標記(?library=kb → 0 筆)。 +// → 依 SDD 預案:在 triplet template schema 加 optional `library` slot(改 template 不動表, +// 見 ensureTripletLibrarySlot);ingest 端補寫值屬 M3。過渡期(舊 triplet 沒有 library slot 值) +// recompute 可帶 source_prefix 參數用 source_uri 前綴當 fallback 過濾——由 caller 提供前綴, +// base 不寫死任何 URI 格式語意(base 對內容語意無知的既有原則)。 +import { createEntry, getEntry } from './entry-crud'; +import { createRecord, createTemplate, getTemplate, updateRecord, updateTemplate } from './record-crud'; + +export const LIBRARY_MAP_TEMPLATE_ID = 'tpl-library-map'; +export const LIBRARY_MAP_TEMPLATE_NAME = 'library_map'; +// 與 migrations/0003_library_map.sql 的 seed 同一份定義(兩邊必須一致)。 +export const LIBRARY_MAP_SLOTS = [ + 'library', + 'narrative', + 'top_entities', + 'relation_profile', + 'bridges', + 'triplet_count', + 'commit_hash', + 'status', +]; +// prod 實際部署的 triplet template 名(kbdb_list_templates 核實);caller 可用參數覆蓋。 +export const DEFAULT_TRIPLET_TEMPLATE = 'triplet'; + +export interface TopEntity { name: string; degree: number } +export interface RelationStat { predicate: string; count: number } +export interface Bridge { entity: string; libraries: string[] } + +export interface LibraryMapRow { + library: string; + narrative: string | null; + top_entities: string[]; // 全館視圖只回 top 3 名字(數百 token 內,design §4 MCP instructions 用) + triplet_count: number; + updated_at: number; +} + +export interface LibraryMapDetail { + record_id: string; + library: string; + narrative: string | null; + content: string | null; // map block 的可嵌人話(design §5,M6 semantic 路由直接用) + top_entities: TopEntity[]; + relation_profile: RelationStat[]; + bridges: Bridge[]; + triplet_count: number; + commit_hash: string | null; + status: string; + updated_at: number; +} + +export interface RecomputeInput { + library: string; + narrative?: string; // wiki 首段抽取屬 ingest 端(M3)——base 只收值不抽取 + commit_hash?: string; + owner_id?: string; + source_prefix?: string; // 過渡 fallback:library slot 缺值的舊 triplet 以 source_uri LIKE 前綴歸庫 + triplet_template?: string; + top_n?: number; // top_entities 取幾個(預設 10、上限 50) +} + +export interface RecomputeResult { + map: LibraryMapDetail; + superseded: string[]; // 被標 superseded 的舊 map record ids + triplet_template: string; + triplet_library_slot_added: boolean; // 本次是否幫 triplet template 補上 optional library slot +} + +// ---- template ensure(M1) ---- + +// library_map template 若不存在就走既有 createTemplate 路徑補建(migration 0003 的 runtime 保險)。 +// UNIQUE(name) 撞到(並發/半套 seed)→ 重讀即可,冪等。 +export async function ensureLibraryMapTemplate(db: D1Database): Promise { + const existing = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME); + if (existing) return; + try { + await createTemplate(db, { + id: LIBRARY_MAP_TEMPLATE_ID, + name: LIBRARY_MAP_TEMPLATE_NAME, + description: 'per-library map block(藏書地圖:graph 機械導出,零 LLM 生成;Arcrun#39)', + slots: LIBRARY_MAP_SLOTS, + created_by: 'system', + }); + } catch { + if (!(await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME))) throw new Error('ensureLibraryMapTemplate failed'); + } +} + +// triplet template schema 加 optional `library` slot(design §1 預案:改 template 不動表)。 +// 只增不減、冪等;ingest 端(M3)開始寫值後,recompute 就能按 slot 精準歸庫,不再靠 source_prefix。 +export async function ensureTripletLibrarySlot(db: D1Database, tripletTemplate: string): Promise { + const tpl = await getTemplate(db, tripletTemplate); + if (!tpl) throw new Error(`triplet template not found: ${tripletTemplate}`); + const slots: string[] = JSON.parse(tpl.slots_json); + if (slots.includes('library')) return false; + await updateTemplate(db, tpl.id, { slots: [...slots, 'library'] }); + return true; +} + +// ---- 聚合 SQL(M2 recompute) ---- + +// record(entry_values 縱表)→ 一列一 triplet 的 pivot。MAX(CASE …) 是 SQLite 縱轉橫慣用法; +// owner filter 直接下在 pivot 前(record 的所有 slot entries 同 owner,createRecord 寫入時同值)。 +function tripletPivotSql(ownerFiltered: boolean): string { + return `SELECT ev.record_id AS rid, + MAX(CASE WHEN ev.slot_name = 'subject' THEN e.content END) AS subject, + MAX(CASE WHEN ev.slot_name = 'object' THEN e.content END) AS object, + MAX(CASE WHEN ev.slot_name = 'predicate' THEN e.content END) AS predicate, + MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status, + MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library, + MAX(CASE WHEN ev.slot_name = 'source_uri' THEN e.content END) AS source_uri + FROM entry_values ev JOIN entries e ON ev.entry_id = e.id + WHERE ev.template_id = ?${ownerFiltered ? ' AND e.owner_id = ?' : ''} + GROUP BY ev.record_id`; +} + +// library_map 自身 record 的 pivot(讀端+supersede 查找共用)。 +function mapPivotSql(ownerFiltered: boolean): string { + return `SELECT ev.record_id AS rid, + MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library, + MAX(CASE WHEN ev.slot_name = 'narrative' THEN e.content END) AS narrative, + MAX(CASE WHEN ev.slot_name = 'top_entities' THEN e.content END) AS top_entities, + MAX(CASE WHEN ev.slot_name = 'relation_profile' THEN e.content END) AS relation_profile, + MAX(CASE WHEN ev.slot_name = 'bridges' THEN e.content END) AS bridges, + MAX(CASE WHEN ev.slot_name = 'triplet_count' THEN e.content END) AS triplet_count, + MAX(CASE WHEN ev.slot_name = 'commit_hash' THEN e.content END) AS commit_hash, + MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status, + MAX(ev.created_at) AS ts + FROM entry_values ev JOIN entries e ON ev.entry_id = e.id + WHERE ev.template_id = ?${ownerFiltered ? ' AND e.owner_id = ?' : ''} + GROUP BY ev.record_id`; +} + +function parseJsonArray(raw: string | null | undefined): T[] { + if (!raw) return []; + try { + const v = JSON.parse(raw); + return Array.isArray(v) ? (v as T[]) : []; + } catch { + return []; + } +} + +export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput): Promise { + const library = input.library.trim(); + if (!library) throw new Error('library required'); + const tripletTemplateName = input.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE; + const topN = Math.min(Math.max(Math.floor(input.top_n ?? 10), 1), 50); + + await ensureLibraryMapTemplate(db); + // 順手把 optional library slot 補進 triplet template(M1;冪等,不動表)。 + const librarySlotAdded = await ensureTripletLibrarySlot(db, tripletTemplateName); + const tripletTpl = await getTemplate(db, tripletTemplateName); + if (!tripletTpl) throw new Error(`triplet template not found: ${tripletTemplateName}`); + + const owner = input.owner_id || undefined; + const pivot = tripletPivotSql(!!owner); + const pivotParams: unknown[] = owner ? [tripletTpl.id, owner] : [tripletTpl.id]; + + // 歸庫謂詞:library slot 優先;caller 給了 source_prefix 才對「沒有 library 值的舊 triplet」 + // 啟用 source_uri 前綴 fallback(M3 backfill 完成前的過渡;base 不解析 URI 語意)。 + const libCond = input.source_prefix + ? `(t.library = ? OR (t.library IS NULL AND t.source_uri LIKE ? || '%'))` + : `t.library = ?`; + const libParams: unknown[] = input.source_prefix ? [library, input.source_prefix] : [library]; + // 只算 active triplet(superseded/deprecated 不進地圖;沒有 status slot 的舊資料視同 active)。 + const withLib = `WITH t AS (${pivot}), lib AS ( + SELECT * FROM t WHERE COALESCE(t.status, 'active') = 'active' AND ${libCond})`; + const baseParams = [...pivotParams, ...libParams]; + + const [countRow, topRes, relRes, bridgeRes] = await Promise.all([ + db.prepare(`${withLib} SELECT COUNT(*) AS n FROM lib`).bind(...baseParams).first<{ n: number }>(), + // degree=entity 在該庫 active triplet 的出現次數(subject+object 兩側都算;同名並列取名字序穩定輸出) + db + .prepare( + `${withLib} SELECT name, COUNT(*) AS degree FROM ( + SELECT subject AS name FROM lib UNION ALL SELECT object AS name FROM lib) + WHERE name IS NOT NULL GROUP BY name ORDER BY degree DESC, name ASC LIMIT ?`, + ) + .bind(...baseParams, topN) + .all<{ name: string; degree: number }>(), + // predicate 分布=庫的「性格」(spec §3 relation_profile) + db + .prepare( + `${withLib} SELECT predicate, COUNT(*) AS n FROM lib + WHERE predicate IS NOT NULL GROUP BY predicate ORDER BY n DESC, predicate ASC LIMIT 100`, + ) + .bind(...baseParams) + .all<{ predicate: string; n: number }>(), + // bridges=本庫 entity 同時出現在其他庫(跨庫 join)。對面那側只能靠 library slot 標記值 + //(source_prefix 只描述本庫的前綴,無法反推他庫)→ M3 backfill 前 bridges 會偏稀疏,誠實現況。 + db + .prepare( + `${withLib}, labeled AS ( + SELECT DISTINCT name, library FROM ( + SELECT subject AS name, library FROM t WHERE COALESCE(status,'active') = 'active' + UNION SELECT object AS name, library FROM t WHERE COALESCE(status,'active') = 'active') + WHERE name IS NOT NULL AND library IS NOT NULL AND library != ?), + mine AS ( + SELECT DISTINCT subject AS name FROM lib WHERE subject IS NOT NULL + UNION SELECT DISTINCT object AS name FROM lib WHERE object IS NOT NULL) + SELECT l.name AS entity, l.library AS library FROM labeled l + JOIN mine m ON m.name = l.name ORDER BY l.name ASC, l.library ASC`, + ) + .bind(...baseParams, library) + .all<{ entity: string; library: string }>(), + ]); + + const tripletCount = countRow?.n ?? 0; + const topEntities: TopEntity[] = (topRes.results ?? []).map((r) => ({ name: r.name, degree: r.degree })); + const relationProfile: RelationStat[] = (relRes.results ?? []).map((r) => ({ predicate: r.predicate, count: r.n })); + // GROUP_CONCAT 不用(entity 名可能含逗號)→ 取 (entity, library) 對在 JS 聚合,上限 50 個橋接點。 + const bridgeMap = new Map(); + for (const r of bridgeRes.results ?? []) { + if (!bridgeMap.has(r.entity) && bridgeMap.size >= 50) continue; + const libs = bridgeMap.get(r.entity) ?? []; + if (!libs.includes(r.library)) libs.push(r.library); + bridgeMap.set(r.entity, libs); + } + const bridges: Bridge[] = [...bridgeMap.entries()].map(([entity, libraries]) => ({ entity, libraries })); + + // map block 的 content=可嵌人話(design §5:之後 M6 semantic 路由第一跳直接嵌這句做庫路由)。 + const narrative = input.narrative?.trim() || ''; + const coreNames = topEntities.slice(0, 3).map((t) => t.name); + const content = `${library}:${narrative || '(narrative 待 ingest 補寫)'}。核心:${ + coreNames.length ? coreNames.join('、') : '(尚無 entities)' + }`; + + // 寫入順序安全(design §2「交易式或至少順序安全」;D1 無跨語句交易): + // 先建新 active block+record,成功後才把舊的標 superseded——中途失敗最壞是多一個 active, + // 讀端一律取最新 active,不會出現「地圖真空」。 + const blockEntry = await createEntry(db, { + content, + entry_type: 'block', + owner_id: owner ?? null, + page_name: `library-map:${library}`, + metadata_json: JSON.stringify({ kind: 'library_map', library }), + }); + const values: Record = { + library, + narrative, + top_entities: JSON.stringify(topEntities), + relation_profile: JSON.stringify(relationProfile), + bridges: JSON.stringify(bridges), + triplet_count: String(tripletCount), + status: 'active', + }; + if (input.commit_hash) values.commit_hash = input.commit_hash; + // record_id=map block entry 的 id:block(人話 content)與 record(結構化 slots)同一身分, + // 讀端一次定位、embed 模組(M6)也直接嵌這顆 entry。 + await createRecord(db, { + template: LIBRARY_MAP_TEMPLATE_NAME, + record_id: blockEntry.id, + values, + owner_id: owner ?? null, + }); + + // 舊 active map(同庫、非本次新建)→ 標 superseded(沿用既有 status slot 語意,R6)。 + const mapTpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME); + const oldParams: unknown[] = owner ? [mapTpl!.id, owner, library, blockEntry.id] : [mapTpl!.id, library, blockEntry.id]; + const oldRes = await db + .prepare( + `WITH m AS (${mapPivotSql(!!owner)}) + SELECT rid FROM m WHERE m.library = ? AND COALESCE(m.status, 'active') = 'active' AND m.rid != ?`, + ) + .bind(...oldParams) + .all<{ rid: string }>(); + const superseded: string[] = []; + for (const row of oldRes.results ?? []) { + await updateRecord(db, row.rid, { status: 'superseded' }); + superseded.push(row.rid); + } + + return { + map: { + record_id: blockEntry.id, + library, + narrative: narrative || null, + content, + top_entities: topEntities, + relation_profile: relationProfile, + bridges, + triplet_count: tripletCount, + commit_hash: input.commit_hash ?? null, + status: 'active', + updated_at: blockEntry.created_at, + }, + superseded, + triplet_template: tripletTemplateName, + triplet_library_slot_added: librarySlotAdded, + }; +} + +// ---- 讀端(M2 GET) ---- + +interface MapPivotRow { + rid: string; + library: string | null; + narrative: string | null; + top_entities: string | null; + relation_profile: string | null; + bridges: string | null; + triplet_count: string | null; + commit_hash: string | null; + status: string | null; + ts: number; +} + +// 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count),MCP instructions +// 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ 誠實回空清單。 +export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise { + const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME); + if (!tpl) return []; + const params: unknown[] = owner_id ? [tpl.id, owner_id] : [tpl.id]; + const res = await db + .prepare( + `WITH m AS (${mapPivotSql(!!owner_id)}) + SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL + ORDER BY m.ts DESC`, + ) + .bind(...params) + .all(); + // 每庫只留最新 active(supersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。 + const byLib = new Map(); + for (const r of res.results ?? []) { + if (!r.library || byLib.has(r.library)) continue; + byLib.set(r.library, { + library: r.library, + narrative: r.narrative || null, + top_entities: parseJsonArray(r.top_entities).slice(0, 3).map((t) => t.name), + triplet_count: Number(r.triplet_count ?? 0) || 0, + updated_at: r.ts, + }); + } + return [...byLib.values()].sort((a, b) => a.library.localeCompare(b.library)); +} + +// 單庫詳圖:完整 slots+map block 的人話 content。 +export async function getLibraryMapDetail( + db: D1Database, + library: string, + owner_id?: string, +): Promise { + const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME); + if (!tpl) return null; + const params: unknown[] = owner_id ? [tpl.id, owner_id, library] : [tpl.id, library]; + const row = await db + .prepare( + `WITH m AS (${mapPivotSql(!!owner_id)}) + SELECT * FROM m WHERE m.library = ? AND COALESCE(m.status, 'active') = 'active' + ORDER BY m.ts DESC LIMIT 1`, + ) + .bind(...params) + .first(); + if (!row) return null; + // record_id=map block entry id(recompute 寫入時綁定);entry 若被外力刪除,content 誠實回 null。 + const blockEntry = await getEntry(db, row.rid); + return { + record_id: row.rid, + library, + narrative: row.narrative || null, + content: blockEntry?.content ?? null, + top_entities: parseJsonArray(row.top_entities), + relation_profile: parseJsonArray(row.relation_profile), + bridges: parseJsonArray(row.bridges), + triplet_count: Number(row.triplet_count ?? 0) || 0, + commit_hash: row.commit_hash || null, + status: row.status ?? 'active', + updated_at: row.ts, + }; +} diff --git a/kbdb/src/index.ts b/kbdb/src/index.ts index f2095ed..a9ad210 100644 --- a/kbdb/src/index.ts +++ b/kbdb/src/index.ts @@ -10,6 +10,7 @@ import { templateRoutes } from './routes/templates'; import { recordRoutes } from './routes/records'; import { recipeStatRoutes } from './routes/recipe-stats'; import { embedRoutes } from './routes/embed'; +import { mapRoutes } from './routes/map'; const app = new Hono<{ Bindings: Bindings }>(); @@ -23,5 +24,8 @@ app.route('/recipe-stats', recipeStatRoutes); // Optional embed module admin (backfill). Route mounts unconditionally; the handler // honestly 409s when the embed binding is off (base 對內容語意無知,只認通用 embed 旗標)。 app.route('/embed', embedRoutes); +// 藏書地圖(library-map SDD M2 / Arcrun#39):聚合 SQL 只准住基本盤(D6 推論), +// recompute+讀端都在這裡;ingest workflow 只透過 HTTP 呼叫(A 類接 B 類 API,牆不破)。 +app.route('/map', mapRoutes); export default app; diff --git a/kbdb/src/routes/map.ts b/kbdb/src/routes/map.ts new file mode 100644 index 0000000..b6831b6 --- /dev/null +++ b/kbdb/src/routes/map.ts @@ -0,0 +1,48 @@ +// Map route — 藏書地圖(library-map SDD M2;源頭 Arcrun#39)。 +// 聚合 SQL 只准住基本盤(D6 推論,design §2 關鍵歸屬裁定)——本 route 是薄殼,SQL 全在 +// actions/library-map.ts。auth 照 base route 既有慣例:raw worker 不驗 key,owner 隔離由 +// cypher proxy(X-Arcrun-API-Key → owner_id 注入)/caller 帶 owner_id 參數完成。 +import { Hono } from 'hono'; +import type { Bindings } from '../types'; +import { getLibraryMapDetail, listLibraryMaps, recomputeLibraryMap } from '../actions/library-map'; + +export const mapRoutes = new Hono<{ Bindings: Bindings }>(); + +// POST /map/recompute?library=X — 對該庫重算地圖(ingest 尾端/backfill 呼叫;R2 增量重算)。 +// body(皆選填):{ narrative, commit_hash, owner_id, source_prefix, triplet_template, top_n } +// - narrative:本輪由 caller 傳入(wiki 首段抽取屬 ingest 端 M3,base 不抽取)。 +// - source_prefix:舊 triplet(無 library slot 值)的 source_uri 前綴 fallback(M3 backfill 前過渡)。 +// 寫入順序安全:先建新 active map block,再把舊 block 標 superseded(見 action 註解)。 +mapRoutes.post('/recompute', async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Record; + const library = c.req.query('library') || (typeof body.library === 'string' ? body.library : ''); + if (!library || !library.trim()) return c.json({ success: false, error: 'library required' }, 400); + try { + const result = await recomputeLibraryMap(c.env.DB, { + library, + narrative: typeof body.narrative === 'string' ? body.narrative : undefined, + commit_hash: typeof body.commit_hash === 'string' ? body.commit_hash : undefined, + owner_id: typeof body.owner_id === 'string' ? body.owner_id : c.req.query('owner_id') || undefined, + source_prefix: typeof body.source_prefix === 'string' ? body.source_prefix : undefined, + triplet_template: typeof body.triplet_template === 'string' ? body.triplet_template : undefined, + top_n: typeof body.top_n === 'number' ? body.top_n : undefined, + }); + return c.json({ success: true, ...result }); + } catch (e) { + return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400); + } +}); + +// GET /map — 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。 +// 形狀給 MCP instructions/GUI 首頁共用(R3/R4),設計在數百 token 內。 +mapRoutes.get('/', async (c) => { + const libraries = await listLibraryMaps(c.env.DB, c.req.query('owner_id') || undefined); + return c.json({ success: true, libraries, count: libraries.length }); +}); + +// GET /map/:library — 該庫詳圖(完整 slots+可嵌人話 content)。 +mapRoutes.get('/:library', async (c) => { + const map = await getLibraryMapDetail(c.env.DB, c.req.param('library'), c.req.query('owner_id') || undefined); + if (!map) return c.json({ success: false, error: 'not found' }, 404); + return c.json({ success: true, map }); +}); diff --git a/kbdb/tests/library-map.test.ts b/kbdb/tests/library-map.test.ts new file mode 100644 index 0000000..9283144 --- /dev/null +++ b/kbdb/tests/library-map.test.ts @@ -0,0 +1,252 @@ +// library-map(藏書地圖)M1+M2 — SDD system-dev/docs/3-specs/library-map(源頭 Arcrun#39)。 +// 測試策略:聚合 SQL(degree 排序/predicate 統計/跨庫 join/supersede 查找)用「真 SQLite」驗—— +// node:sqlite(Node ≥22.5 內建,零新依賴)跑 migrations/0001+0003 原檔,比 capture-DB 只驗 SQL +// 形狀更硬;route 行為(參數解析/400/404/回應形狀)走 Hono app.request,與既有測試同款。 +// 註:D1 語意與 SQLite 幾乎同源,僅 session/consistency 層不同——本測試覆蓋的純 SQL 聚合在兩邊等價。 +import { describe, it, expect } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync } from 'node:fs'; +import { Hono } from 'hono'; +import { mapRoutes } from '../src/routes/map'; +import { + recomputeLibraryMap, + listLibraryMaps, + getLibraryMapDetail, + ensureTripletLibrarySlot, + LIBRARY_MAP_SLOTS, +} from '../src/actions/library-map'; +import { createTemplate, createRecord, getRecord, getTemplate } from '../src/actions/record-crud'; +import type { Bindings } from '../src/types'; + +// ── node:sqlite → D1 介面最小 adapter(prepare/bind/all/first/run,本 codebase 只用這些)── +function makeSqliteD1(): D1Database { + const raw = new DatabaseSync(':memory:'); + raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); + raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8')); + function stmt(sql: string, params: unknown[]) { + const s = { + bind(...args: unknown[]) { return stmt(sql, args); }, + async all() { return { results: raw.prepare(sql).all(...params) as T[] }; }, + async first() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, + async run() { raw.prepare(sql).run(...params); return { success: true }; }, + }; + return s; + } + return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; +} + +// prod 實際 triplet template 的 slots(2026-07-19 kbdb_list_templates 核實)——注意:沒有 library。 +const PROD_TRIPLET_SLOTS = [ + 'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json', + 'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by', + 'source_uri', 'content_hash', 'source_anchor', 'predicate_embed', +]; + +async function seedTripletTemplate(db: D1Database): Promise { + await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' }); +} + +async function seedTriplet( + db: D1Database, + v: { s: string; p: string; o: string; library?: string; source_uri?: string; status?: string }, + owner = 'leo', +): Promise { + const values: Record = { subject: v.s, predicate: v.p, object: v.o }; + if (v.library) values.library = v.library; + if (v.source_uri) values.source_uri = v.source_uri; + if (v.status) values.status = v.status; + const rec = await createRecord(db, { template: 'triplet', values, owner_id: owner }); + return rec.record_id; +} + +function makeApp(db: D1Database) { + const app = new Hono<{ Bindings: Bindings }>(); + app.route('/map', mapRoutes); + const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings; + return { app, env }; +} + +describe('M1 — library_map template+triplet library slot(真 SQLite)', () => { + it('migration 0003 seed:library_map template 落在 templates 表、slots 齊全(D6 零建表)', async () => { + const db = makeSqliteD1(); + const tpl = await getTemplate(db, 'library_map'); + expect(tpl).not.toBeNull(); + expect(JSON.parse(tpl!.slots_json)).toEqual(LIBRARY_MAP_SLOTS); + }); + + it('ensureTripletLibrarySlot:prod 形狀(無 library slot)→ 補上;再跑冪等 false', async () => { + const db = makeSqliteD1(); + await seedTripletTemplate(db); + expect(await ensureTripletLibrarySlot(db, 'triplet')).toBe(true); + const tpl = await getTemplate(db, 'triplet'); + expect(JSON.parse(tpl!.slots_json)).toContain('library'); + expect(await ensureTripletLibrarySlot(db, 'triplet')).toBe(false); // 只增不減、冪等 + }); + + it('recompute 對缺 triplet template 的 DB → 誠實丟錯(不假裝算出空地圖)', async () => { + const db = makeSqliteD1(); + await expect(recomputeLibraryMap(db, { library: 'kb' })).rejects.toThrow('triplet template not found'); + }); +}); + +describe('M2 — recompute 聚合(真 SQLite 實跑 SQL)', () => { + async function seedKbLibrary(db: D1Database) { + await seedTripletTemplate(db); + await ensureTripletLibrarySlot(db, 'triplet'); + // kb 庫:A 出現 3 次(degree 3)、B 2 次、C 1 次;predicate 連結至×2、屬於×1 + await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); + await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' }); + await seedTriplet(db, { s: 'B', p: '屬於', o: 'A', library: 'kb' }); + } + + it('top_entities degree 排序+relation_profile+triplet_count+content 可嵌人話', async () => { + const db = makeSqliteD1(); + await seedKbLibrary(db); + const r = await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫卡片', commit_hash: 'abc123' }); + expect(r.map.triplet_count).toBe(3); + expect(r.map.top_entities).toEqual([ + { name: 'A', degree: 3 }, + { name: 'B', degree: 2 }, + { name: 'C', degree: 1 }, + ]); + expect(r.map.relation_profile).toEqual([ + { predicate: '連結至', count: 2 }, + { predicate: '屬於', count: 1 }, + ]); + // design §5:content = `{library}:{narrative}。核心:{top 前幾個}`(M6 semantic 路由直接嵌) + expect(r.map.content).toBe('kb:知識庫卡片。核心:A、B、C'); + expect(r.map.commit_hash).toBe('abc123'); + expect(r.superseded).toEqual([]); + expect(r.triplet_library_slot_added).toBe(false); // seed 已補過 slot → recompute 冪等回 false + }); + + it('superseded triplet 不入地圖(COALESCE status active-only)', async () => { + const db = makeSqliteD1(); + await seedKbLibrary(db); + await seedTriplet(db, { s: 'X', p: '連結至', o: 'Y', library: 'kb', status: 'superseded' }); + const r = await recomputeLibraryMap(db, { library: 'kb' }); + expect(r.map.triplet_count).toBe(3); + expect(r.map.top_entities.map((t) => t.name)).not.toContain('X'); + }); + + it('source_prefix fallback:舊 triplet(無 library 值)靠 source_uri 前綴歸庫;不帶 fallback 則不計', async () => { + const db = makeSqliteD1(); + await seedTripletTemplate(db); + // 模擬 prod 現況:triplet 只有 source_uri(gitea:Leo/kb@…),沒有 library slot 值 + await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', source_uri: 'gitea:Leo/kb@system-dev/wiki/cards/kb/00-INDEX.md#seg02' }); + await seedTriplet(db, { s: 'B', p: '連結至', o: 'C', source_uri: 'gitea:Leo/kb@system-dev/wiki/cards/kb/x.md' }); + await seedTriplet(db, { s: 'D', p: '連結至', o: 'E', source_uri: 'gitea:Leo/notes@cards/y.md' }); // 他庫 + const strict = await recomputeLibraryMap(db, { library: 'kb' }); + expect(strict.map.triplet_count).toBe(0); // library slot 全空 → 嚴格模式誠實回 0 + const fb = await recomputeLibraryMap(db, { library: 'kb', source_prefix: 'gitea:Leo/kb@' }); + expect(fb.map.triplet_count).toBe(2); + expect(fb.map.top_entities.map((t) => t.name)).toEqual(['B', 'A', 'C']); + }); + + it('bridges:本庫 entity 出現在其他庫(library slot 標記側)→ 跨庫 join 列出', async () => { + const db = makeSqliteD1(); + await seedKbLibrary(db); + await seedTriplet(db, { s: 'A', p: '參與', o: 'Z', library: 'notes' }); // A 橫跨 kb/notes + const r = await recomputeLibraryMap(db, { library: 'kb' }); + expect(r.map.bridges).toEqual([{ entity: 'A', libraries: ['notes'] }]); + }); + + it('owner 隔離:帶 owner_id 只聚合該 owner 的 triplet(照 base 既有 owner 慣例)', async () => { + const db = makeSqliteD1(); + await seedTripletTemplate(db); + await ensureTripletLibrarySlot(db, 'triplet'); + await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }, 'tenant1'); + await seedTriplet(db, { s: 'C', p: '連結至', o: 'D', library: 'kb' }, 'tenant2'); + const r = await recomputeLibraryMap(db, { library: 'kb', owner_id: 'tenant1' }); + expect(r.map.triplet_count).toBe(1); + expect(r.map.top_entities.map((t) => t.name).sort()).toEqual(['A', 'B']); + }); + + it('supersede 順序安全:重算兩次 → 舊 map 標 superseded、讀端只見最新 active', async () => { + const db = makeSqliteD1(); + await seedKbLibrary(db); + const first = await recomputeLibraryMap(db, { library: 'kb', narrative: '第一版' }); + await seedTriplet(db, { s: 'A', p: '連結至', o: 'D', library: 'kb' }); + const second = await recomputeLibraryMap(db, { library: 'kb', narrative: '第二版' }); + expect(second.superseded).toEqual([first.map.record_id]); // 舊 active 被點名 + const oldRec = await getRecord(db, first.map.record_id); + expect(oldRec!.values.status).toBe('superseded'); // 沿用既有 status slot 語意(R6) + const detail = await getLibraryMapDetail(db, 'kb'); + expect(detail!.record_id).toBe(second.map.record_id); + expect(detail!.narrative).toBe('第二版'); + expect(detail!.triplet_count).toBe(4); + // 全館視圖同樣只剩一行 kb(superseded 不重複出現) + const all = await listLibraryMaps(db); + expect(all.filter((r) => r.library === 'kb')).toHaveLength(1); + }); +}); + +describe('M2 — route 行為(GET /map、GET /map/:library、POST /map/recompute)', () => { + it('POST /map/recompute 缺 library → 400;query 傳 library+body 傳 narrative 可重算', async () => { + const db = makeSqliteD1(); + await seedTripletTemplate(db); + const { app, env } = makeApp(db); + const bad = await app.request('/map/recompute', { method: 'POST' }, env); + expect(bad.status).toBe(400); + const ok = await app.request('/map/recompute?library=kb', { + method: 'POST', + body: JSON.stringify({ narrative: '知識庫' }), + headers: { 'content-type': 'application/json' }, + }, env); + expect(ok.status).toBe(200); + const body = (await ok.json()) as { success: boolean; map: { library: string; content: string } }; + expect(body.success).toBe(true); + expect(body.map.library).toBe('kb'); + expect(body.map.content).toContain('kb:知識庫'); + }); + + it('GET /map:從未 recompute(template 不存在)→ 誠實空清單;有資料 → 每庫一行、top 3 名字', async () => { + const empty = makeSqliteD1(); + // 空 DB 連 library_map template 都拿掉,模擬「migration 未跑、也從未 recompute」的自架環境 + await (empty as unknown as { prepare(sql: string): { run(): Promise } }) + .prepare("DELETE FROM templates WHERE name = 'library_map'").run(); + const e = makeApp(empty); + const r0 = await e.app.request('/map', {}, e.env); + expect(r0.status).toBe(200); + expect(await r0.json()).toEqual({ success: true, libraries: [], count: 0 }); + + const db = makeSqliteD1(); + await seedTripletTemplate(db); + await ensureTripletLibrarySlot(db, 'triplet'); + // kb 庫塞 4 個 entities(驗 top_entities 全館視圖只留 3) + await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); + await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' }); + await seedTriplet(db, { s: 'A', p: '連結至', o: 'D', library: 'kb' }); + await seedTriplet(db, { s: 'X', p: '參與', o: 'Y', library: 'notes' }); + await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫' }); + await recomputeLibraryMap(db, { library: 'notes', narrative: '隨手筆記' }); + const { app, env } = makeApp(db); + const res = await app.request('/map', {}, env); + const body = (await res.json()) as { libraries: { library: string; narrative: string; top_entities: string[]; triplet_count: number }[]; count: number }; + expect(body.count).toBe(2); + const kb = body.libraries.find((l) => l.library === 'kb')!; + expect(kb.narrative).toBe('知識庫'); + expect(kb.triplet_count).toBe(3); + expect(kb.top_entities).toEqual(['A', 'B', 'C']); // 每庫一行只留 top 3(R3 數百 token 內) + }); + + it('GET /map/:library:完整 slots+content;未知庫 404', async () => { + const db = makeSqliteD1(); + await seedTripletTemplate(db); + await ensureTripletLibrarySlot(db, 'triplet'); + await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); + await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫', commit_hash: 'deadbeef' }); + const { app, env } = makeApp(db); + const res = await app.request('/map/kb', {}, env); + expect(res.status).toBe(200); + const body = (await res.json()) as { map: Record }; + expect(body.map.library).toBe('kb'); + expect(body.map.commit_hash).toBe('deadbeef'); + expect(body.map.status).toBe('active'); + expect(Array.isArray(body.map.relation_profile)).toBe(true); + expect(Array.isArray(body.map.bridges)).toBe(true); + expect(body.map.content).toBe('kb:知識庫。核心:A、B'); + const miss = await app.request('/map/nope', {}, env); + expect(miss.status).toBe(404); + }); +}); diff --git a/kbdb/tests/node-builtins.d.ts b/kbdb/tests/node-builtins.d.ts new file mode 100644 index 0000000..3c7cf5f --- /dev/null +++ b/kbdb/tests/node-builtins.d.ts @@ -0,0 +1,21 @@ +// 測試用 node 內建模組最小型別宣告。tsconfig types 只掛 @cloudflare/workers-types(worker 本體 +// 不該看見 node API),但測試在 node 跑:library-map 測試用 node:sqlite 當真 SQLite 驗聚合 SQL +//(零新依賴;Node ≥22.5 內建)。這份 d.ts 只為 tsc --noEmit 過關,不影響 runtime。 +declare module 'node:sqlite' { + export class DatabaseSync { + constructor(location: string); + exec(sql: string): void; + prepare(sql: string): { + all(...params: unknown[]): Record[]; + get(...params: unknown[]): Record | undefined; + run(...params: unknown[]): { changes: number | bigint }; + }; + close(): void; + } +} +declare module 'node:fs' { + export function readFileSync(path: URL | string, encoding: string): string; +} +// workers-types 的 ImportMeta 沒宣告 url(worker bundle 不用);測試以 import.meta.url 定位 +// migrations 原檔 → 全域補上(僅型別,node/vitest runtime 本來就有)。 +interface ImportMeta { url: string } diff --git a/system-dev/docs/3-specs/library-map/design.md b/system-dev/docs/3-specs/library-map/design.md index 8310d7a..8a73e5c 100644 --- a/system-dev/docs/3-specs/library-map/design.md +++ b/system-dev/docs/3-specs/library-map/design.md @@ -10,6 +10,8 @@ note: leo 2026-07-19「藏書地圖可以走」=design confirmed 可動工(M 三元組按庫過濾:先核實現況——rag-ingest-cards v2 的 triplet 已帶 `source_uri`、entries 已有 `metadata.library`(portal-auth P1);若 triplet 定位庫仍不足,在 Triplet **Template schema** 加 optional `library` slot(改 template 不動表)。 +> **核實結果(2026-07-19,M1 對 prod 實查)**:triplet template(prod 名 `triplet`,非 MCP 註解寫的 `graph_triplet`)slots=subject/predicate/object/…/source_uri/…,**無 `library` slot**;entries 的 `metadata.library` 機制在但既有資料未標記(`?library=kb` → 0 筆)→ **定位庫不足,走預案**:M1 於 recompute 時冪等地在 triplet template 加 optional `library` slot(`ensureTripletLibrarySlot`,改 template 不動表);slot 值由 ingest 端補寫(M3)。過渡期 `/map/recompute` 收 optional `source_prefix` 參數,對「無 library 值的舊 triplet」以 `source_uri` 前綴歸庫(如 `gitea:Leo/kb@`)——前綴由 caller 提供,base 不寫死 URI 語意;bridges 的「對面庫」只認 library slot 標記值,M3 backfill 前會偏稀疏(誠實限制)。 + ## 2. 重算的家:SQL 只能住基本盤(D6 推論,本 SDD 關鍵歸屬裁定) degree 排序/predicate 統計/跨庫 join 是聚合 SQL——**D6 鐵律:插件與 workflow 全程禁 SQL**。故重算實作=**kbdb base 新增內建端點 `POST /map/recompute?library=`**(基本盤內 SQL 合法,一段交易:算→建新 block→舊標 superseded)。ingest workflow(A 類)尾端用 `http_request` 呼此端點——A 類接 B 類 API,牆不破。 diff --git a/system-dev/docs/3-specs/library-map/tasks.md b/system-dev/docs/3-specs/library-map/tasks.md index ff99121..75d1e14 100644 --- a/system-dev/docs/3-specs/library-map/tasks.md +++ b/system-dev/docs/3-specs/library-map/tasks.md @@ -4,8 +4,8 @@ | # | 任務 | 類 | 依賴 | 狀態 | 備註 | |---|---|---|---|---|---| -| M1 | `library_map` Template+slots 定義(含 triplet 按庫過濾現況核實;不足則 Triplet template 加 optional library slot) | B | — | ⬜ | D6 零建表 | -| M2 | kbdb base `POST /map/recompute?library=`+`GET /map`/`GET /map/:library`(聚合 SQL 住基本盤;交易式 supersede) | B | M1 | ⬜ | PR+測試;merge 後 gated 部署(leo 閘) | +| 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 同族薄殼 | | M5 | GUI 首頁:全館地圖 render(console+portal) | B | M2 | ⬜ | 取代空白搜尋框 | diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index 2a52f27..5ec3872 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -15,6 +15,16 @@ metadata: ## 📍 當前位置 +> **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 按庫定位= +> **不足**(prod triplet template 無 library slot、entries metadata.library 未標記)→ 走 SDD 預案: +> recompute 冪等補 optional `library` slot(改 template 不動表),值待 ingest M3 補寫。 +> M2:kbdb base `POST /map/recompute?library=`(degree top-N/predicate 分布/跨庫 bridges/count, +> 聚合 SQL 住基本盤=D6 歸屬裁定;順序安全 supersede;過渡 `source_prefix` fallback 讓舊 triplet 靠 +> source_uri 歸庫)+`GET /map`(每庫一行)+`GET /map/:library`。測試:node:sqlite 真 SQLite 實跑 +> 聚合 SQL+Hono route 行為,45/45 過、tsc 乾淨。 +> > **2026-07-19(#68 MCP graph tool)**:`kbdb_graph_neighbors` PR 已開(分支 > `feat/mcp-graph-neighbors-tool`),等審+gated 部署(merge 後需 leo 閘 redeploy arcrun-mcp)。 > 薄殼:MCP 新 tool 調 `/q/:ns/graph_neighbors` 同步查詢端點(#28 地基),補齊 D17 KBDB MCP