// 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, }; }