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,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 });
|
||||
});
|
||||
Reference in New Issue
Block a user