diff --git a/console-ui/public/console/index.html b/console-ui/public/console/index.html
index 9ddb2f3..a392fdf 100644
--- a/console-ui/public/console/index.html
+++ b/console-ui/public/console/index.html
@@ -862,7 +862,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
var libs = x.d.libraries || [];
if (!libs.length) {
- lmHonest('還沒有藏書地圖', '還沒有任何庫跑過重算——對 KBDB 呼 POST /map/recompute?library=庫名 backfill 後,這裡會出現全館導覽。
不影響下方搜尋,可直接搜全庫。');
+ lmHonest('還沒有藏書地圖', '這個租戶目前沒有任何三元組資料(地圖是查詢時即時核對重算的,不是要人手動 backfill——資料一進來下次載入就會出現)。
不影響下方搜尋,可直接搜全庫。');
return;
}
LM.libs = libs; LM.details = {};
diff --git a/kbdb/src/actions/library-map.ts b/kbdb/src/actions/library-map.ts
index 776344e..8f817f1 100644
--- a/kbdb/src/actions/library-map.ts
+++ b/kbdb/src/actions/library-map.ts
@@ -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;
+type LibraryNameSet = Set;
+
+// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
+// triplet 歸 'general')——與 GET /records/triplet-stats(t142)同一套分組語意,兩處數字對得上。
+async function liveTripletCountsByLibrary(
+ db: D1Database,
+ tripletTemplateId: string,
+ owner_id?: string,
+): Promise {
+ 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 {
+ 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 {
+ 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();
+ 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 {
diff --git a/kbdb/src/routes/map.ts b/kbdb/src/routes/map.ts
index b6831b6..b3182df 100644
--- a/kbdb/src/routes/map.ts
+++ b/kbdb/src/routes/map.ts
@@ -4,7 +4,12 @@
// 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';
+import {
+ ensureFreshLibraryMaps,
+ getLibraryMapDetail,
+ listLibraryMaps,
+ recomputeLibraryMap,
+} from '../actions/library-map';
export const mapRoutes = new Hono<{ Bindings: Bindings }>();
@@ -35,14 +40,25 @@ mapRoutes.post('/recompute', async (c) => {
// GET /map — 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。
// 形狀給 MCP instructions/GUI 首頁共用(R3/R4),設計在數百 token 內。
+//
+// 2026-08-08:讀前先 ensureFreshLibraryMaps(即時新鮮度層,見 actions/library-map.ts 段落註解)——
+// 不再只讀靜態快取,讀的當下順手核對即時三元組數、落差就地補算。失敗吞掉不擋讀取(地圖是加分)。
mapRoutes.get('/', async (c) => {
- const libraries = await listLibraryMaps(c.env.DB, c.req.query('owner_id') || undefined);
+ const owner = c.req.query('owner_id') || undefined;
+ await ensureFreshLibraryMaps(c.env.DB, owner).catch(() => {});
+ const libraries = await listLibraryMaps(c.env.DB, owner);
return c.json({ success: true, libraries, count: libraries.length });
});
// GET /map/:library — 該庫詳圖(完整 slots+可嵌人話 content)。
+// 同樣先跑即時新鮮度層。之後仍查不到 → 誠實 404(這個名字這個租戶的資料裡從沒出現過,
+// 不是「這庫是空的」——已知但目前 0 三元組的庫會被上一步補成一筆 triplet_count:0 的 map,
+// 走得到 200,不會落到這條 404)。
mapRoutes.get('/:library', async (c) => {
- const map = await getLibraryMapDetail(c.env.DB, c.req.param('library'), c.req.query('owner_id') || undefined);
+ const owner = c.req.query('owner_id') || undefined;
+ const library = c.req.param('library');
+ await ensureFreshLibraryMaps(c.env.DB, owner).catch(() => {});
+ const map = await getLibraryMapDetail(c.env.DB, library, owner);
if (!map) return c.json({ success: false, error: 'not found' }, 404);
return c.json({ success: true, map });
});
diff --git a/kbdb/tests/library-map.test.ts b/kbdb/tests/library-map.test.ts
index 9283144..0d76e5c 100644
--- a/kbdb/tests/library-map.test.ts
+++ b/kbdb/tests/library-map.test.ts
@@ -13,9 +13,11 @@ import {
listLibraryMaps,
getLibraryMapDetail,
ensureTripletLibrarySlot,
+ ensureFreshLibraryMaps,
LIBRARY_MAP_SLOTS,
} from '../src/actions/library-map';
import { createTemplate, createRecord, getRecord, getTemplate } from '../src/actions/record-crud';
+import { createEntry } from '../src/actions/entry-crud';
import type { Bindings } from '../src/types';
// ── node:sqlite → D1 介面最小 adapter(prepare/bind/all/first/run,本 codebase 只用這些)──
@@ -250,3 +252,100 @@ describe('M2 — route 行為(GET /map、GET /map/:library、POST /map/recompu
expect(miss.status).toBe(404);
});
});
+
+// 2026-08-08:M3 收尾——真因是「等外部呼叫 /map/recompute」這條線三週沒人接(總管實測 grep
+// 全 repo 查無呼叫點),沒手動 backfill 過的租戶恆空。修法:讀端自己核對即時三元組數,落差
+// 就地補算,不再依賴任何外部呼叫者。以下驗證這條「即時新鮮度」機制本身。
+describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動核對重算,不靠外部呼叫 recompute)', () => {
+ it('從未手動呼過 recompute:GET /map 第一次讀就自動補齊(全租戶自動 backfill)', async () => {
+ const db = makeSqliteD1();
+ await seedTripletTemplate(db);
+ await ensureTripletLibrarySlot(db, 'triplet');
+ await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
+ await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
+ await seedTriplet(db, { s: 'X', p: '參與', o: 'Y', library: 'notes' });
+ // 注意:這裡沒有呼叫 recomputeLibraryMap,直接打 GET /map。
+ const { app, env } = makeApp(db);
+ const res = await app.request('/map', {}, env);
+ const body = (await res.json()) as { libraries: { library: string; triplet_count: number }[]; count: number };
+ expect(body.count).toBe(2);
+ const kb = body.libraries.find((l) => l.library === 'kb')!;
+ expect(kb.triplet_count).toBe(2);
+ const notes = body.libraries.find((l) => l.library === 'notes')!;
+ expect(notes.triplet_count).toBe(1);
+ });
+
+ it('跟得上資料:先讀一次,再塞新三元組,下一次讀(不手動 recompute)數字要更新', async () => {
+ const db = makeSqliteD1();
+ await seedTripletTemplate(db);
+ await ensureTripletLibrarySlot(db, 'triplet');
+ await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
+ const { app, env } = makeApp(db);
+ const first = await app.request('/map', {}, env);
+ const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
+ expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
+
+ // 模擬 ingest 進了一筆新資料——不呼叫任何 recompute。
+ await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
+ const second = await app.request('/map', {}, env);
+ const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
+ expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
+ });
+
+ it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
+ const db = makeSqliteD1();
+ await seedTripletTemplate(db);
+ await ensureTripletLibrarySlot(db, 'triplet');
+ await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
+ await recomputeLibraryMap(db, { library: 'kb', narrative: '人工填過的摘要' });
+ // 塞新三元組觸發下一次讀時的自動重算(不帶 narrative)。
+ await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
+ await ensureFreshLibraryMaps(db);
+ const detail = await getLibraryMapDetail(db, 'kb');
+ expect(detail!.triplet_count).toBe(2); // 確認真的有重算(不是沒動過)
+ expect(detail!.narrative).toBe('人工填過的摘要'); // 但 narrative 沒被洗掉
+ });
+
+ it('GET /map/:library 誠實分辨「查無此庫」(404) vs「已知但目前是空庫」(200+triplet_count:0)', async () => {
+ const db = makeSqliteD1();
+ await seedTripletTemplate(db);
+ await ensureTripletLibrarySlot(db, 'triplet');
+ // 'hr' 庫:entries 蓋過章(t52 慣例)但目前沒有任何三元組——已知但空。
+ await createEntry(db, {
+ content: '人資資料',
+ entry_type: 'block',
+ owner_id: 'leo',
+ metadata_json: JSON.stringify({ library: 'hr' }),
+ });
+ const { app, env } = makeApp(db);
+
+ const known = await app.request('/map/hr?owner_id=leo', {}, env);
+ expect(known.status).toBe(200); // 已知庫,即使是空的也回 200,不是 404
+ const knownBody = (await known.json()) as { map: { triplet_count: number } };
+ expect(knownBody.map.triplet_count).toBe(0);
+
+ const unknown = await app.request('/map/totally-made-up-name?owner_id=leo', {}, env);
+ expect(unknown.status).toBe(404); // 真的從沒出現過的名字才 404
+ });
+
+ it('owner 隔離:即時新鮮度層不會把別的 owner 的三元組算進來', async () => {
+ const db = makeSqliteD1();
+ await seedTripletTemplate(db);
+ await ensureTripletLibrarySlot(db, 'triplet');
+ await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }, 'tenant1');
+ await seedTriplet(db, { s: 'C', p: '連結至', o: 'D', library: 'kb' }, 'tenant2');
+ const { app, env } = makeApp(db);
+ const res = await app.request('/map?owner_id=tenant1', {}, env);
+ const body = (await res.json()) as { libraries: { library: string; triplet_count: number }[] };
+ expect(body.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
+ });
+
+ it('沒有 triplet template(這顆 KBDB 從沒建過任何三元組)→ 不報錯,誠實回空清單', async () => {
+ // 新鮮 DB:只跑過 migrations(library_map template 有 seed,但沒人叫過 seedTripletTemplate)。
+ const fresh = makeSqliteD1();
+ await expect(ensureFreshLibraryMaps(fresh)).resolves.toBeUndefined();
+ const { app, env } = makeApp(fresh);
+ const res = await app.request('/map', {}, env);
+ expect(await res.json()).toEqual({ success: true, libraries: [], count: 0 });
+ });
+});
diff --git a/mcp/src/tools/kbdb_map.ts b/mcp/src/tools/kbdb_map.ts
index 80397f0..167b005 100644
--- a/mcp/src/tools/kbdb_map.ts
+++ b/mcp/src/tools/kbdb_map.ts
@@ -23,10 +23,19 @@ import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js";
-/** 空庫/404 時的 backfill 指引(誠實回報+給下一步,鐵律:不假綠)。 */
+/**
+ * 空庫/404 時的指引(誠實回報+給下一步,鐵律:不假綠)。
+ *
+ * 2026-08-08 修正:舊版寫「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過(總管實測
+ * grep 全 repo 查無任何呼叫點),是假話。真正的機制:kbdb `GET /map`/`GET /map/:library`
+ * 每次查詢都會自動核對即時三元組數,落差就地重算(`kbdb/src/actions/library-map.ts`
+ * `ensureFreshLibraryMaps`)——資料進來後下一次查詢就會反映,不需要任何人記得呼叫任何端點。
+ * `POST /map/recompute` 仍然存在,但只在極少數情況才需要手動打:舊三元組沒有 `library` slot
+ * 值、只能靠 `source_uri` 前綴回填時(`source_prefix` 參數)。
+ */
const RECOMPUTE_HINTS = [
- "地圖由 ingest 尾端自動重算(M3);尚未接鏈的庫要手動 backfill:對 kbdb 呼 POST /map/recompute?library=<庫名>(可帶 body {narrative, source_prefix})",
- "backfill 過渡期(triplet 還沒有 library slot 值)用 source_prefix 以 source_uri 前綴歸庫,如 {\"source_prefix\":\"gitea:Leo/kb@\"}",
+ "地圖每次查詢都會自動核對即時三元組數並重算過期的庫,不必手動處理",
+ "少見情況(舊三元組沒有 library 標記)才需要手動:POST /map/recompute?library=<庫名>(可帶 body {narrative, source_prefix})",
];
/** 註冊全部藏書地圖工具(library-map M4)。 */
@@ -89,9 +98,11 @@ export function registerGetMap(server: McpServer, env: Env) {
triplet_count: Number(l.triplet_count ?? 0) || 0,
}));
if (libraries.length === 0) {
- // 空庫誠實回報:不是錯誤(端點正常、就是還沒有地圖),給 backfill 指引。
+ // 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
+ // 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
+ // 不再是「沒人跑過 recompute」那種曖昧狀態。
return successResponse({ libraries: [], count: 0 }, [
- "全館地圖是空的:還沒有任何庫跑過 recompute",
+ "全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
...RECOMPUTE_HINTS,
]);
}
@@ -104,9 +115,13 @@ export function registerGetMap(server: McpServer, env: Env) {
// 單庫詳圖:完整 slots(slot 陣列 parse 成物件再回)。
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
if (res.status === 404) {
+ // 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
+ // 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
+ // 代表這個名字在這個租戶的資料裡從沒出現過——不是「這庫是空的」,是根本沒有這個庫
+ // (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
return errorResponse(
"map_not_found",
- `庫「${library}」還沒有地圖(從未 recompute,或庫名打錯)`,
+ `查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS],
);
}
@@ -129,7 +144,7 @@ export function registerGetMap(server: McpServer, env: Env) {
triplet_count: Number(raw.triplet_count ?? 0) || 0,
};
return successResponse({ map }, [
- "bridges=此庫 entity 同時出現在哪些其他庫(M3 backfill 前會偏稀疏,是誠實現況不是 bug)",
+ "bridges=此庫 entity 同時出現在哪些其他庫(只有兩側三元組都標了 library 值才抓得到,舊資料若沒標會偏稀疏,是誠實現況不是 bug)",
"沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)",
]);
} catch (e) {
diff --git a/mcp/tests/unit/tools/kbdb-map.test.ts b/mcp/tests/unit/tools/kbdb-map.test.ts
index 3a20612..77ea6d3 100644
--- a/mcp/tests/unit/tools/kbdb-map.test.ts
+++ b/mcp/tests/unit/tools/kbdb-map.test.ts
@@ -127,6 +127,25 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
expect(JSON.stringify(body.hints)).toContain("POST /map/recompute");
});
+ // 2026-08-08:舊版說明文字宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過
+ // (總管實測 grep 全 repo 查無任何呼叫點),是假話。修法:GET /map 讀端本身自動核對
+ // 即時三元組數並補算,不靠任何外部呼叫者。這裡釘住那句謊言不會再出現在任何 hint 裡。
+ it("不再宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過,是假話(已修正措辭)", async () => {
+ const { server, tools } = makeServer();
+ const { env } = makeEnv(
+ () => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
+ );
+ registerGetMap(server, env);
+ const res = await tools.get("kbdb_get_map")!.handler({});
+ const body = parseResult(res);
+ const hintsText = JSON.stringify(body.hints);
+ expect(hintsText).not.toContain("地圖由 ingest 尾端自動重算");
+ expect(hintsText).not.toContain("(M3)");
+ expect(hintsText).not.toContain("(M3)");
+ // 誠實的新措辭:空=這個租戶真的沒資料,不是「沒人跑過 recompute」
+ expect(hintsText).toContain("這個租戶目前沒有任何三元組資料");
+ });
+
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
@@ -199,6 +218,10 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const body = parseResult(res);
expect(body.error_code).toBe("map_not_found");
expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
+ // 2026-08-08:404 現在的語意是「查無此庫」(地圖是即時核對重算的,已知庫即使空也回 200),
+ // 不再是舊版那句「從未 recompute」的曖昧說法。
+ expect(String(body.human_message)).toContain("查無庫");
+ expect(String(body.human_message)).toContain("從沒出現過");
});
it("binding throws → internal_error, not an unhandled crash", async () => {
diff --git a/system-dev/docs/3-specs/library-map/design.md b/system-dev/docs/3-specs/library-map/design.md
index 8a73e5c..b340fd6 100644
--- a/system-dev/docs/3-specs/library-map/design.md
+++ b/system-dev/docs/3-specs/library-map/design.md
@@ -21,6 +21,18 @@ degree 排序/predicate 統計/跨庫 join 是聚合 SQL——**D6 鐵律:
ingest 完成 → 取本次 commit diff 涉及的庫集合 → 逐庫呼 `/map/recompute`。無 cron 全量。首次 backfill=對每個既有庫手動各呼一次(installer/腳本一行)。
+> **2026-08-08 更正(matrix/arcrun CC)**:上面這條「ingest 尾端接鏈」的路徑本身沒錯(`arcrun-rag`
+> 那條管線也確實接了,`670f38a`),但它**只覆蓋接了鏈的那一條 ingest**——這個 repo 內(kbdb/
+> cypher-executor/mcp)從沒有任何呼叫點會打 `/map/recompute`,導致沒手動 backfill 過的租戶
+> (絕大多數)恆空,且拖了三週沒人發現/接上(見 `system-dev/wiki/mistakes.md` 08-08 段)。
+> **改法**(leo 否決「降級成即時聚合、不維護快取」的提案,因為那會丟失 narrative 這類摘要
+> 本體):`GET /map`/`GET /map/:library` 讀端自己核對即時三元組數,落差就地呼叫既有的
+> `recomputeLibraryMap` 補算(`kbdb/src/actions/library-map.ts` `ensureFreshLibraryMaps`)。
+> 聚合 SQL 仍只住 kbdb base(沒有違反 §2 的歸屬裁定),只是觸發時機從「等外部呼叫」改成
+> 「讀的當下順手核對」——這條讀端機制本身就是「無 cron 全量」的自動 backfill,取代了
+> 「首次 backfill=對每個既有庫手動各呼一次」這句手動步驟。細節見
+> `system-dev/docs/3-specs/library-map/tasks.md` M3 段。
+
## 4. 注入(leo spec §5,本功能重點)
- **MCP instructions**:arcrun-mcp 啟動組 instructions 時拉 `GET /map` 嵌入(快取+TTL,或每次連線現拉——量數百 token,現拉可接受)。
diff --git a/system-dev/docs/3-specs/library-map/tasks.md b/system-dev/docs/3-specs/library-map/tasks.md
index fb9aa63..adbe99b 100644
--- a/system-dev/docs/3-specs/library-map/tasks.md
+++ b/system-dev/docs/3-specs/library-map/tasks.md
@@ -6,8 +6,41 @@
|---|---|---|---|---|---|
| M1 | `library_map` Template+slots 定義(含 triplet 按庫過濾現況核實;不足則 Triplet template 加 optional library slot) | B | — | ✅ 07-19(PR#72 merge) | D6 零建表。核實:triplet 無 library slot → 已走預案(design §1 核實結果) |
| M2 | kbdb base `POST /map/recompute?library=`+`GET /map`/`GET /map/:library`(聚合 SQL 住基本盤;交易式 supersede) | B | M1 | ✅ 07-19(PR#72 merge;leo21c `33d88016`+demo `699018af` 已部署+backfill:leo21c kb 111/notes 108、demo general 23) | PR+測試(真 SQLite 驗聚合);merge 後 gated 部署(leo 閘)+逐庫 backfill recompute |
-| M3 | ingest 尾端接鏈:diff 涉及庫 → 逐庫呼 recompute(rag-ingest-cards v2+個人庫 ingest 同款改版) | A | M2 | ✅ 07-19(arcrun-rag `670f38a`;demo e2e 雙向通過:push/刪卡皆自動 recompute;leo21c 等 T-flip) | workflow 改版走 bundle 分發 |
+| M3 | 讓地圖跟得上資料、全租戶自動 backfill(原訂做法:ingest 尾端接鏈逐庫呼 recompute) | B | M2 | 🔁 **07-19 標的 ✅ 是誤報,08-08 更正並改法重做** | 見下方 08-08 段 |
| M4 | MCP:instructions 注入全館地圖+`get_map` 工具 | B | M2 | ✅ 07-19(PR#73 merge;leo21c `1e73da90` 部署,MCP instructions 實載地圖) | 與 #68 同族薄殼;`kbdb_get_map`+connect 時注入(isolate TTL 快取,失敗靜默略過不擋連線);merge 後 gated redeploy arcrun-mcp(leo 閘) |
| M5 | GUI 首頁:全館地圖 render(console+portal) | B | M2 | ✅ 07-19(PR#74 merge;leo21c cypher `9bc3a1f3` 部署) | 取代空白搜尋框 |
| M6 | D30 連動:map 層 embed+semantic 庫路由第一跳 | B | M2 | ⬜ | #58/#59/#60 家族的第一片治本 |
| M7 | dogfood:leo 庫(leo21c)首個實例 backfill+驗收(requirements 驗收段全項) | A | M3-M5 | 🔄 demo 側實質驗過;leo21c 正式驗收單(requirements 全項)待做 | 過了才進 demo/客戶 |
+
+### M3 更正(2026-08-08,matrix/arcrun CC,總管交辦)
+
+**07-19 標 ✅ 是誤報**:`arcrun-rag 670f38a` 只接了 rag-ingest-cards 那一條管線,**repo 內
+(`grep -rn "map/recompute" --include=*.ts` 排除 node_modules/.github-public)查無任何呼叫點**,
+三週來沒手動 backfill 過的租戶(絕大多數、含 youlin 與所有新租戶)`GET /map` 恆回空,
+`kbdb_get_map` 的 MCP 說明文字還宣稱「地圖由 ingest 尾端自動重算(M3)」——那是假話(詳見
+`system-dev/wiki/mistakes.md` 08-08 段「藏書地圖 `kbdb_get_map` 對多數租戶永遠是空的」)。
+
+**leo 裁示**:總管原提兩案(①接上 M3 原訂設計/②降級成只算 count 的即時聚合,不維護快取),
+leo 否決②——「**藏書地圖就是 arcrun 的最重要功能,讓 AI 一眼看到所有庫的摘要**」,
+即時聚合算得出 count、算不出 narrative,降級等於砍功能。
+
+**改法(非①非②,第三案)**:不再依賴任何外部呼叫者(ingest workflow)記得呼
+`POST /map/recompute`——那條線跨 repo/跨租戶,已證實三週沒人接上,天生脆弱。改成
+`GET /map`/`GET /map/:library` 讀端自己核對即時三元組數,落差就地呼叫既有的
+`recomputeLibraryMap`(`kbdb/src/actions/library-map.ts` `ensureFreshLibraryMaps`)補算。
+聚合 SQL 沒有第二套、narrative/relation_profile/bridges 這些摘要欄位原封不動——不是砍成
+只算數字,只是觸發時機從「等外部呼叫」改成「讀的當下順手核對」。同時解掉:
+① 全租戶自動 backfill(不需要任何人做任何事)② 跟得上資料(下一筆 ingest 進來,
+下一次讀就反映)③ 不再依賴跨 repo 的 ingest 接鏈。
+
+`narrative` 欄位有一個已知誠實限制:這個機制只**保留**既有 narrative(不會被自動重算洗掉),
+但不會**生成**新的 narrative——沒人手動填過、也沒有 ingest 端寫過的庫,narrative 仍是空的
+(`content` 顯示「(narrative 待 ingest 補寫)」)。這是 design §1 本來就承認的缺口
+(narrative 抽自 wiki 首段,屬內容語意萃取,非聚合 SQL 能生出來),非本次新增。
+
+**驗證**:`kbdb/tests/library-map.test.ts` 新增 6 案(全綠,18/18)涵蓋:從未手動 recompute
+即自動補齊/跟得上新資料(不手動重算,數字自動更新)/narrative 不被靜默洗掉/
+`GET /map/:library` 誠實分辨查無此庫(404) vs 已知空庫(200)/owner 隔離/無 triplet template
+不報錯。`mcp/tests/unit/tools/kbdb-map.test.ts` 新增 1 案釘住舊謊言不再出現(18/18 全綠)。
+tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前
+`kbdb_get_map` 回 `{libraries:[],count:0}`——改動待部署後需重新實測驗證非空。