Files
Arcrun/kbdb/src/actions/library-map.ts
T
uncle6me-web 962d863ef7 fix(kbdb): 藏書地圖 M3 收尾——讀端自動核對重算,不再依賴 ingest 接鏈
真因(總管實測,system-dev/wiki/mistakes.md 08-08 段):design 原訂「ingest 尾端呼
POST /map/recompute」,但 repo 內查無任何呼叫點,三週沒接上,沒手動 backfill 過的租戶
(絕大多數)GET /map 恆回空;MCP 說明文字還宣稱「地圖由 ingest 尾端自動重算(M3)」——假話。

leo 否決「降級成即時聚合、不維護快取」的提案(會丟失 narrative 這類摘要本體,只算得出
count)。改法:GET /map/GET /map/:library 讀端自己核對即時三元組數,落差就地呼叫既有的
recomputeLibraryMap 補算(kbdb/src/actions/library-map.ts ensureFreshLibraryMaps)。聚合
SQL 沒有第二套、narrative/relation_profile/bridges 摘要欄位原封不動,只是觸發時機從「等
外部呼叫」改成「讀的當下順手核對」。同時解掉:全租戶自動 backfill/跟得上新資料/不依賴
跨 repo 的 ingest 接鏈。

附帶修 recomputeLibraryMap 的 narrative 欄位:沒帶值時原本會清空,改成沿用上一版(避免
自動重算把 ingest 端/人工填過的 narrative 靜默洗掉)。

修正三處說謊的說明文字(mcp/src/tools/kbdb_map.ts、console-ui console/index.html):
「地圖由 ingest 尾端自動重算(M3)」不存在,改為誠實描述讀端即時核對機制;404 語意從
「從未 recompute」改為「查無此庫」(已知但空的庫現在會自動補成 triplet_count:0 的 200,
不會落到 404)。

測試:kbdb 新增 6 案(18/18 全綠,覆蓋自動 backfill/跟得上資料/narrative 保留/
404 vs 空庫誠實分辨/owner 隔離/無 triplet template 不報錯);mcp 新增 1 案釘住舊謊言
不再出現。kbdb 125/125、mcp 69/77(同基線 8 個 oauth 既有失敗,非本次引入)全綠;
tsc 兩包乾淨(kbdb 1 個既有 auth.test.ts 錯誤與 stash 前一致,非本次引入)。

SDD:system-dev/docs/3-specs/library-map/tasks.md M3 從「07-19 誤標 」更正為實況;
design.md §3 加 2026-08-08 更正說明。未動 frontmatter status(仍 draft,D35 生命週期
鐵律留給總管/leo 裁)。

殘項:本次修改只在本機驗證(真 SQLite + 假 binding 單元測試),未部署 prod;未在真實
KBDB(如 yuga3bse 租戶)重新實測 kbdb_get_map 非空——需部署後才能貼實測輸出。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:44:55 +08:00

507 lines
24 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(藏書地圖)— 聚合 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 路由第一跳直接嵌這句做庫路由)。
// narrativecaller 有給才覆蓋;沒給 → 沿用上一版現有 narrative(若有)。
// 2026-08-08 修正:這欄原本「沒給就清空」,會被下面新增的即時新鮮度層
// ensureFreshLibraryMaps,讀端自動重算、天生不帶 narrative)每次呼叫都靜默洗掉
// ingest 端/人工填過的 narrative——沒給值=維持現狀,不是重置成空字串。
let narrative = input.narrative?.trim();
if (!narrative) {
const prev = await getLibraryMapDetail(db, library, owner);
narrative = prev?.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,
};
}
// ---- 即時新鮮度(M3 收尾,2026-08-08 ----
//
// 真因(總管實測+wiki system-dev/wiki/mistakes.md「08-08」段):design §3 原訂「ingest 完成 →
// 逐庫呼 POST /map/recompute」,但 repo 內查無任何呼叫點——三週沒接上,導致沒手動 backfill 過的
// 租戶(絕大多數)GET /map 恆回空,且 M4 的 MCP 說明文字還宣稱「地圖由 ingest 尾端自動重算」
// (不存在的事)。leo 拍板此功能是 arcrun 最重要的入口(「讓 AI 一眼看到所有庫的摘要」),
// 且明確否決「降級成只算 count 的即時聚合」(那樣會丟失 narrativerelation_profilebridges
// 這些 summary 本體,narrative 沒辦法從純聚合 SQL 現算出來)。
//
// 解法:不再依賴任何外部呼叫者記得呼 /map/recompute,改成讀端(GET /map、GET /map/:library
// 自己核對即時三元組數,落差就地呼叫既有的 recomputeLibraryMap 補算——聚合 SQL 沒有第二套,
// 只是觸發時機從「等外部呼叫」改成「讀的當下順手核對」。這同時解掉三件事:
// 一、全租戶自動 backfill(不需要用戶或任何人做任何事,第一次讀就會補齊)
// 二、跟得上資料(下一筆 ingest 進來,觸發計數變化,下一次讀就重算,不是靜態快照)
// 三、不依賴 ingest workflow 那端的接鏈(那條線跨 repo/跨租戶天生脆弱,已證實三週沒人接上)
// narrativerelation_profilebridges 這些「摘要」欄位仍走 recomputeLibraryMap 原封不動的邏輯,
// 不是砍成只算數字——與 leo 否決的「降級方案」不同款。
// 型別別名:避免巢狀泛型連寫(Map/Set 的收尾兩個角括號會被 workflow 意圖語法的三段箭頭規則
// 誤判成 `>> `),純粹是繞開該 lint 的寫法選擇,語意不變。
type LibraryCountMap = Map<string, number>;
type LibraryNameSet = Set<string>;
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
// triplet 歸 'general')——與 GET /records/triplet-statst142)同一套分組語意,兩處數字對得上。
async function liveTripletCountsByLibrary(
db: D1Database,
tripletTemplateId: string,
owner_id?: string,
): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
const res = await db
.prepare(
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
FROM (
SELECT DISTINCT ev.record_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
) AS tr
LEFT JOIN entry_values lev ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
const m: LibraryCountMap = new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
// 「已知庫名」集合:即使目前三元組數是 0,只要蓋過章(entries metadata.libraryt52 慣例)或
// 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的
// 「這庫是空的」(回 200triplet_count:0vs「查無此庫」(回 404)。kbdb base 對 portal_library
// 的語意無知,只是把它當一個普通 template 讀 name slot(不違反 D6 base 對內容語意無知的既有原則)。
async function knownLibraryNames(db: D1Database, owner_id?: string): Promise<LibraryNameSet> {
const names: LibraryNameSet = new Set();
const entryParams: unknown[] = owner_id ? [owner_id] : [];
const entryRows = await db
.prepare(
`SELECT DISTINCT json_extract(metadata_json, '$.library') AS library FROM entries
WHERE ${owner_id ? 'owner_id = ?' : '1=1'} AND json_extract(metadata_json, '$.library') IS NOT NULL`,
)
.bind(...entryParams)
.all<{ library: string | null }>();
for (const r of entryRows.results ?? []) if (r.library) names.add(r.library);
const libTpl = await getTemplate(db, 'portal_library');
if (libTpl) {
const libParams: unknown[] = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
const libRows = await db
.prepare(
`SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id`,
)
.bind(...libParams)
.all<{ name: string | null }>();
for (const r of libRows.results ?? []) if (r.name) names.add(r.name);
}
return names;
}
// 核對+補算:這個 owner 底下所有「即時有三元組」或「已知但地圖過期/缺失」的庫,一次核對、
// 只對真的落差的庫重算(平行跑,單庫失敗不擋其他庫、不擋讀取——地圖是加分不是硬依賴)。
// 沒有 triplet template(這顆 KBDB 從沒建過任何三元組)→ 無地圖可算,直接返回,不報錯。
export async function ensureFreshLibraryMaps(
db: D1Database,
owner_id?: string,
tripletTemplateName: string = DEFAULT_TRIPLET_TEMPLATE,
): Promise<void> {
const tripletTpl = await getTemplate(db, tripletTemplateName);
if (!tripletTpl) return;
const [liveCounts, cached, known] = await Promise.all([
liveTripletCountsByLibrary(db, tripletTpl.id, owner_id),
listLibraryMaps(db, owner_id),
knownLibraryNames(db, owner_id),
]);
const cachedByLib = new Map(cached.map((m) => [m.library, m]));
const stale = new Set<string>();
for (const [library, count] of liveCounts) {
const c = cachedByLib.get(library);
if (!c || c.triplet_count !== count) stale.add(library);
}
// 已知庫但目前沒有三元組、也從沒算過地圖 → 補算一次讓它以「空庫」現身(triplet_count:0),
// 不是完全消失;已經算過的空庫不重複補(避免對永遠空的庫每次都白重算)。
for (const name of known) {
if (!liveCounts.has(name) && !cachedByLib.has(name)) stale.add(name);
}
await Promise.all(
[...stale].map((library) =>
recomputeLibraryMap(db, { library, owner_id, triplet_template: tripletTemplateName }).catch(() => {
// 單庫重算失敗(如聚合 SQL 撞到髒資料)不擋其他庫、不擋讀取——鐵律:地圖是加分不是依賴。
}),
),
);
}
// ---- 讀端(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,
};
}