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>
This commit is contained in:
@@ -226,7 +226,15 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
|
||||
const bridges: Bridge[] = [...bridgeMap.entries()].map(([entity, libraries]) => ({ entity, libraries }));
|
||||
|
||||
// map block 的 content=可嵌人話(design §5:之後 M6 semantic 路由第一跳直接嵌這句做庫路由)。
|
||||
const narrative = input.narrative?.trim() || '';
|
||||
// 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)'
|
||||
@@ -297,6 +305,127 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 即時新鮮度(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 {
|
||||
|
||||
Reference in New Issue
Block a user