ceb7638d74
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。 模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」 - 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數 (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+ 每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列 (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。 - record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留, 驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。 - entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry 接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。 - 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列, LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀), GET /maintenance/relation-orphans 唯讀巡檢。 - cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS, 整檔送 /query 會在重跑時假紅)。 - 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描; 釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。 遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型 與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
587 lines
31 KiB
TypeScript
587 lines
31 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;
|
||
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於「這庫沒有知識」——
|
||
// entries(ingest 進來的原始卡片/skill 內容)與 triplet(從 entries 萃取出的三元組)是兩件事,
|
||
// 一個庫可能有大量 entries、但三元組萃取從沒對它跑過(實測:kb 以外 7 庫皆此況,entries 存在
|
||
// 且 library 標記正確,triplet 一筆都沒有)。entry_count 讓讀端(AI/MCP 渲染層)分得清
|
||
// 「真的沒東西」與「有東西但還沒被萃取成三元組」,不再把後者誤讀成前者、誤判庫是空的而放棄查詢
|
||
// (見本檔 liveEntryCountsByLibrary 的計數口徑)。
|
||
entry_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;
|
||
entry_count: number; // 同 LibraryMapRow.entry_count(見該欄位註解)
|
||
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 → 一列一 triplet 的 pivot(0007 樹狀 record 模型:格子=關係列)。
|
||
// b=歸屬關係列(rel=sys_belongs, dst=template 的 sheet entry)=record 成員名單;
|
||
// r=該 record 的格子關係列;v=格子指到的內容 entry。欄位謂詞 id 決定性衍生
|
||
// (fld_<template>_<slot>,與 0007 遷移、record-crud fieldEntryId 同一條規則)⇒
|
||
// 直接用字串串接比對,不必 JOIN field entry。MAX(CASE …) 縱轉橫慣用法照舊;
|
||
// owner filter 下在歸屬關係列的 owner_id(createRecord 寫入時同值,0007 遷移同一推導)。
|
||
// 參數簽名與舊版逐字相同:[template_id, owner?]。
|
||
function tripletPivotSql(ownerFiltered: boolean): string {
|
||
return `SELECT b.src_id AS rid,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_subject' THEN v.content END) AS subject,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_object' THEN v.content END) AS object,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_predicate' THEN v.content END) AS predicate,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_source_uri' THEN v.content END) AS source_uri
|
||
FROM entries b
|
||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||
LEFT JOIN entries v ON v.id = r.dst_id
|
||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? ' AND b.owner_id = ?' : ''}
|
||
GROUP BY b.src_id`;
|
||
}
|
||
|
||
// library_map 自身 record 的 pivot(讀端+supersede 查找共用;同上 0007 形狀)。
|
||
function mapPivotSql(ownerFiltered: boolean): string {
|
||
return `SELECT b.src_id AS rid,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_narrative' THEN v.content END) AS narrative,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_top_entities' THEN v.content END) AS top_entities,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_relation_profile' THEN v.content END) AS relation_profile,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_bridges' THEN v.content END) AS bridges,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_triplet_count' THEN v.content END) AS triplet_count,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_commit_hash' THEN v.content END) AS commit_hash,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||
MAX(r.created_at) AS ts
|
||
FROM entries b
|
||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||
LEFT JOIN entries v ON v.id = r.dst_id
|
||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? ' AND b.owner_id = ?' : ''}
|
||
GROUP BY b.src_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);
|
||
}
|
||
|
||
// entry_count(Arcrun#87 三次收尾):recompute 只重算三元組那一半,entry_count 是即時算的
|
||
// 另一半,兩者同樣的道理一起回傳(見 LibraryMapDetail.entry_count 欄位註解)。
|
||
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
|
||
|
||
return {
|
||
map: {
|
||
record_id: blockEntry.id,
|
||
library,
|
||
narrative: narrative || null,
|
||
content,
|
||
top_entities: topEntities,
|
||
relation_profile: relationProfile,
|
||
bridges,
|
||
triplet_count: tripletCount,
|
||
entry_count: entryCount,
|
||
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)同一套分組語意,兩處數字對得上。
|
||
//
|
||
// 2026-08-11 修根因(Arcrun#87,動工前量測 comment 第四節):這裡原本完全不過濾 status,
|
||
// 而 recomputeLibraryMap(上方 withLib)只算 COALESCE(status,'active')='active'。兩邊判準不
|
||
// 一致,只要有一筆 superseded triplet,這裡的即時計數就會跟重算後的快取對不上,
|
||
// ensureFreshLibraryMaps 判定 stale,每次讀地圖都觸發重算,每次都新建一筆 library_map
|
||
// record(superseded 舊的),無止盡寫 D1,且加劇 recomputeLibraryMap 本身非原子 supersede
|
||
// 的競態(另一個已知病,wiki 08-10 條目)。實測:間隔數秒連讀兩次地圖、中間無任何寫入動作,
|
||
// updated_at 仍前進。修法:這裡的 status 判準改成與 recomputeLibraryMap 逐字一致,兩邊算出
|
||
// 的計數才會在資料未變動時相等,stale 判定回歸「真的有資料變動才 stale」。
|
||
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( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
|
||
`SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n
|
||
FROM (
|
||
SELECT b.src_id AS rid,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library
|
||
FROM entries b
|
||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||
LEFT JOIN entries v ON v.id = r.dst_id
|
||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? ' AND b.owner_id = ?' : ''}
|
||
GROUP BY b.src_id
|
||
) AS tr
|
||
WHERE COALESCE(tr.status, 'active') = 'active'
|
||
GROUP BY COALESCE(NULLIF(tr.library, ''), '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;
|
||
}
|
||
|
||
// 這個 owner 底下、依 entries 自己的 metadata_json.$.library 分組的即時「原始內容」數
|
||
//(Arcrun#87 三次收尾,2026-08-13)——與 liveTripletCountsByLibrary 算的是兩張完全不同的帳:
|
||
// 那個算「萃取出幾條三元組」,這個算「ingest 進來幾筆原始卡片/skill/block」。兩者天生會不一樣
|
||
// (萃取是下游、有延遲甚至從沒對某些庫跑過),刻意分開算、分開回傳,不是同一數字的兩種寫法。
|
||
//
|
||
// 排除口徑(都是結構性排除,不是內容語意判斷——沒有違反「base 對內容語意無知」):
|
||
// - entry_type='value':record-crud.ts 的通用機制,任何 template(含 triplet 本身)建 record
|
||
// 時每個 slot 值都會落一筆這種 entry,是儲存實作細節,不是使用者看得到的「一筆知識」。
|
||
// - metadata.kind='library_map' 的 block:地圖自己每次 recompute 產出的摘要 block(含歷史
|
||
// superseded 的),算進去會自我膨脹、且無限接近雞生蛋——地圖不該把自己算進地圖裡。
|
||
async function liveEntryCountsByLibrary(db: D1Database, owner_id?: string): Promise<LibraryCountMap> {
|
||
const params: unknown[] = owner_id ? [owner_id] : [];
|
||
const res = await db
|
||
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
|
||
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
|
||
COUNT(*) AS n
|
||
FROM entries
|
||
WHERE ${owner_id ? 'owner_id = ? AND ' : ''}entry_type != 'value'
|
||
AND src_id IS NULL
|
||
AND entry_type NOT IN ('record', 'sheet', 'field', 'system')
|
||
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
|
||
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), '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 r.rel_id = 'fld_' || b.dst_id || '_name' THEN v.content END) AS name
|
||
FROM entries b
|
||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||
LEFT JOIN entries v ON v.id = r.dst_id
|
||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? ' AND b.owner_id = ?' : ''}
|
||
GROUP BY b.src_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+entry_count),MCP
|
||
// instructions 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→
|
||
// 誠實回空清單。entry_count 與 triplet_count 並排回傳(Arcrun#87 三次收尾):一個查「原始內容
|
||
// 有沒有」、一個查「萃取出幾條三元組」,兩者不同源、允許不一致(見 liveEntryCountsByLibrary
|
||
// 註解),讀端不該把其中一個的 0 當成另一個的答案。
|
||
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, entryCounts] = await Promise.all([
|
||
db
|
||
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
|
||
`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>(),
|
||
liveEntryCountsByLibrary(db, owner_id),
|
||
]);
|
||
// 每庫只留最新 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,
|
||
entry_count: entryCounts.get(r.library) ?? 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。
|
||
// entry_count 與全館視圖(listLibraryMaps)同一套計法(liveEntryCountsByLibrary),單庫詳圖
|
||
// 只取自己那一庫的數字——Arcrun#87 三次收尾,理由見 LibraryMapDetail.entry_count 欄位註解。
|
||
const [blockEntry, entryCounts] = await Promise.all([
|
||
getEntry(db, row.rid),
|
||
liveEntryCountsByLibrary(db, owner_id),
|
||
]);
|
||
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,
|
||
entry_count: entryCounts.get(library) ?? 0,
|
||
commit_hash: row.commit_hash || null,
|
||
status: row.status ?? 'active',
|
||
updated_at: row.ts,
|
||
};
|
||
}
|