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
This commit is contained in:
@@ -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'
|
||||
);
|
||||
@@ -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<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` slot(design §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;
|
||||
}
|
||||
|
||||
// ---- 聚合 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<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 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<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 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<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_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<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>();
|
||||
// 每庫只留最新 active(supersede 失敗殘留多個 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));
|
||||
}
|
||||
|
||||
// 單庫詳圖:完整 slots+map 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_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<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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 — 全館地圖:每庫一行(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 });
|
||||
});
|
||||
@@ -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<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 的 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<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 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<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 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<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);
|
||||
});
|
||||
});
|
||||
Vendored
+21
@@ -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<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 沒宣告 url(worker 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-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,牆不破。
|
||||
|
||||
@@ -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 | ⬜ | 取代空白搜尋框 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user