Files
Arcrun/kbdb/tests/library-map.test.ts
T
Claude 15fe012006 feat(kbdb): 藏書地圖 M1+M2 — library_map template+/map 端點(聚合 SQL 住基本盤)(#39)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3
2026-07-19 08:40:22 +00:00

253 lines
13 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.
// 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);
});
});