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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user