962d863ef7
真因(總管實測,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>
507 lines
24 KiB
TypeScript
507 lines
24 KiB
TypeScript
// 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 路由第一跳直接嵌這句做庫路由)。
|
||
// narrative:caller 有給才覆蓋;沒給 → 沿用上一版現有 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 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,
|
||
};
|
||
}
|
||
|
||
// ---- 即時新鮮度(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 的即時聚合」(那樣會丟失 narrative/relation_profile/bridges
|
||
// 這些 summary 本體,narrative 沒辦法從純聚合 SQL 現算出來)。
|
||
//
|
||
// 解法:不再依賴任何外部呼叫者記得呼 /map/recompute,改成讀端(GET /map、GET /map/:library)
|
||
// 自己核對即時三元組數,落差就地呼叫既有的 recomputeLibraryMap 補算——聚合 SQL 沒有第二套,
|
||
// 只是觸發時機從「等外部呼叫」改成「讀的當下順手核對」。這同時解掉三件事:
|
||
// 一、全租戶自動 backfill(不需要用戶或任何人做任何事,第一次讀就會補齊)
|
||
// 二、跟得上資料(下一筆 ingest 進來,觸發計數變化,下一次讀就重算,不是靜態快照)
|
||
// 三、不依賴 ingest workflow 那端的接鏈(那條線跨 repo/跨租戶天生脆弱,已證實三週沒人接上)
|
||
// narrative/relation_profile/bridges 這些「摘要」欄位仍走 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-stats(t142)同一套分組語意,兩處數字對得上。
|
||
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.library,t52 慣例)或
|
||
// 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的
|
||
// 「這庫是空的」(回 200+triplet_count:0)vs「查無此庫」(回 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;
|
||
}
|
||
|
||
// 全館地圖:每庫一行(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,
|
||
};
|
||
}
|