feat(kbdb): 藏書地圖 M1+M2 — library_map template+/map 三端點(聚合 SQL 住基本盤) #72

Merged
Leo merged 1 commits from feat/library-map-base into main 2026-07-19 08:42:43 +00:00
9 changed files with 731 additions and 2 deletions
+15
View File
@@ -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 blockrecord)+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'
);
+377
View File
@@ -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 templateprod 名 '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 §5M6 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; // 過渡 fallbacklibrary 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 ensureM1 ----
// library_map template 若不存在就走既有 createTemplate 路徑補建(migration 0003 的 runtime 保險)。
// UNIQUE(name) 撞到(並發/半套 seed)→ 重讀即可,冪等。
export async function ensureLibraryMapTemplate(db: D1Database): Promise<void> {
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` slotdesign §1 預案:改 template 不動表)。
// 只增不減、冪等;ingest 端(M3)開始寫值後,recompute 就能按 slot 精準歸庫,不再靠 source_prefix。
export async function ensureTripletLibrarySlot(db: D1Database, tripletTemplate: string): Promise<boolean> {
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;
}
// ---- 聚合 SQLM2 recompute ----
// recordentry_values 縱表)→ 一列一 triplet 的 pivot。MAX(CASE …) 是 SQLite 縱轉橫慣用法;
// owner filter 直接下在 pivot 前(record 的所有 slot entries 同 ownercreateRecord 寫入時同值)。
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<T>(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<RecomputeResult> {
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 templateM1;冪等,不動表)。
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 前綴 fallbackM3 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 tripletsuperseded/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 }>(),
// degreeentity 在該庫 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<string, string[]>();
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 blockrecord,成功後才把舊的標 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<string, string> = {
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_idmap block entry 的 idblock(人話 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;
}
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count),MCP instructions
// 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ 誠實回空清單。
export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise<LibraryMapRow[]> {
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<MapPivotRow>();
// 每庫只留最新 activesupersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。
const byLib = new Map<string, LibraryMapRow>();
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<TopEntity>(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));
}
// 單庫詳圖:完整 slotsmap block 的人話 content。
export async function getLibraryMapDetail(
db: D1Database,
library: string,
owner_id?: string,
): Promise<LibraryMapDetail | null> {
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<MapPivotRow>();
if (!row) return null;
// record_idmap block entry idrecompute 寫入時綁定);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<TopEntity>(row.top_entities),
relation_profile: parseJsonArray<RelationStat>(row.relation_profile),
bridges: parseJsonArray<Bridge>(row.bridges),
triplet_count: Number(row.triplet_count ?? 0) || 0,
commit_hash: row.commit_hash || null,
status: row.status ?? 'active',
updated_at: row.ts,
};
}
+4
View File
@@ -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;
+48
View File
@@ -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 不驗 keyowner 隔離由
// cypher proxyX-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 端 M3base 不抽取)。
// - source_prefix:舊 triplet(無 library slot 值)的 source_uri 前綴 fallbackM3 backfill 前過渡)。
// 寫入順序安全:先建新 active map block,再把舊 block 標 superseded(見 action 註解)。
mapRoutes.post('/recompute', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
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 — 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count)。
// 形狀給 MCP instructionsGUI 首頁共用(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 });
});
+252
View File
@@ -0,0 +1,252 @@
// library-map(藏書地圖)M1+M2 — SDD system-dev/docs/3-specs/library-map(源頭 Arcrun#39)。
// 測試策略:聚合 SQLdegree 排序/predicate 統計/跨庫 joinsupersede 查找)用「真 SQLite」驗——
// node:sqliteNode ≥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 介面最小 adapterprepare/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<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; },
async first<T>() { 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 的 slots2026-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<void> {
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<string> {
const values: Record<string, string> = { 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 templatetriplet library slot(真 SQLite', () => {
it('migration 0003 seedlibrary_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('ensureTripletLibrarySlotprod 形狀(無 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_profiletriplet_countcontent 可嵌人話', 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 §5content = `{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_urigitea: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 → 400query 傳 librarybody 傳 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:從未 recomputetemplate 不存在)→ 誠實空清單;有資料 → 每庫一行、top 3 名字', async () => {
const empty = makeSqliteD1();
// 空 DB 連 library_map template 都拿掉,模擬「migration 未跑、也從未 recompute」的自架環境
await (empty as unknown as { prepare(sql: string): { run(): Promise<unknown> } })
.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 3R3 數百 token 內)
});
it('GET /map/:library:完整 slotscontent;未知庫 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<string, unknown> };
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);
});
});
+21
View File
@@ -0,0 +1,21 @@
// 測試用 node 內建模組最小型別宣告。tsconfig types 只掛 @cloudflare/workers-typesworker 本體
// 不該看見 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<string, unknown>[];
get(...params: unknown[]): Record<string, unknown> | 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 沒宣告 urlworker bundle 不用);測試以 import.meta.url 定位
// migrations 原檔 → 全域補上(僅型別,node/vitest runtime 本來就有)。
interface ImportMeta { url: string }
@@ -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-19M1 對 prod 實查)**triplet templateprod 名 `triplet`,非 MCP 註解寫的 `graph_triplet`slotssubject/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 workflowA 類)尾端用 `http_request` 呼此端點——A 類接 B 類 API,牆不破。
+2 -2
View File
@@ -4,8 +4,8 @@
| # | 任務 | 類 | 依賴 | 狀態 | 備註 |
|---|---|---|---|---|---|
| M1 | `library_map` Templateslots 定義(含 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` Templateslots 定義(含 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 涉及庫 → 逐庫呼 recomputerag-ingest-cards v2+個人庫 ingest 同款改版) | A | M2 | ⬜ | workflow 改版走 bundle 分發 |
| M4 | MCPinstructions 注入全館地圖+`get_map` 工具 | B | M2 | ⬜ | 與 #68 同族薄殼 |
| M5 | GUI 首頁:全館地圖 renderconsoleportal | B | M2 | ⬜ | 取代空白搜尋框 |
+10
View File
@@ -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` templatemigration 0003 seedruntime ensureD6 零建表);核實 triplet 按庫定位=
> **不足**prod triplet template 無 library slot、entries metadata.library 未標記)→ 走 SDD 預案:
> recompute 冪等補 optional `library` slot(改 template 不動表),值待 ingest M3 補寫。
> M2kbdb base `POST /map/recompute?library=`degree top-Npredicate 分布/跨庫 bridgescount
> 聚合 SQL 住基本盤=D6 歸屬裁定;順序安全 supersede;過渡 `source_prefix` fallback 讓舊 triplet 靠
> source_uri 歸庫)+`GET /map`(每庫一行)+`GET /map/:library`。測試:node:sqlite 真 SQLite 實跑
> 聚合 SQLHono 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