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}`——改動待部署後需重新實測驗證非空。