Files
Arcrun/kbdb/tests/triplet-library-backfill.test.ts
T
uncle6me-web b6ef0f07dc fix(kbdb): 開通 PATCH /kbdb/records/:id proxy + 補三元組 library 補標測試
leo 2026-08-11:貼 wiki 卡「這裏就有 3 個三元組,如果三元組是 0 一定是有錯」——
三元組沒有不見(新式 1,633 筆/舊式 636 筆都還在),問題是地圖看不到:新式三元組
只有 171 筆填了 library slot,其餘因寫入時 template 還沒有該 slot 而被靜默丟棄
(record-crud.ts createRecord 只替「已宣告」的 slot 建值,不是替 caller 傳的
values 建值——順序錯了值就消失,不報錯)。

本次改動:
- cypher-executor/src/routes/kbdb-proxy.ts:補 PATCH /kbdb/records/:recordId。
  基本盤(kbdb/src/routes/records.ts)早有這個端點(mira-dissolve T2.1 的
  updateRecord),但這條 proxy 之前只轉發 GET/POST /kbdb/records,外部(工作流/
  CLI/補標腳本)打不到——能力在,通道沒開,補標三元組只能繞去改表(違 D38)。
  純轉發、無業務邏輯,比照既有 PATCH /kbdb/entries/:id 慣例。
- kbdb/tests/triplet-library-backfill.test.ts:真 node:sqlite 驗三件事——
  ①源頭順序(ensure-slot 必須在 write 之前,事後補救不了已寫的那筆,重現+
  驗證正解)②存量補標(地圖輸出前後對照)③不會重複做(同批跑兩次,第二次
  touch 0 筆)。順手記錄一個相關但不在本次範圍的小落差:liveTripletCountsByLibrary
  的 'general' fallback 桶與 recomputeLibraryMap 的精確比對語意沒對齊。
- cypher-executor/tests/kbdb-records-patch-proxy.test.ts:新端點的租戶閘/
  參數驗證/轉發/404 透傳。

全數綠燈:kbdb 181/181、cypher-executor 新增 8 案全過(既有 14 案失敗為
pre-existing,已用 git stash 比對確認與本次改動無關)。

kbdb-graph-plugin(實際的三元組寫入端 createTriplet/ingestEnvelope)需要同款
修正(library 補進 TRIPLET_SLOTS + 從 source_uri 推導),但該 repo 現行
0 份 active SDD,其 sdd-guard 明確要求人決定要不要開啟——本次不越界代為決定,
留在報告中交回。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:48:31 +08:00

199 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 三元組 library 補標 — 源頭順序 + 存量補標 + 冪等(2026-08-11leo 貼 wiki 卡「三元組要恢復」)
//
// 背景(system-dev/wiki/ops-facts.md「三元組在 KBDB 有兩代儲存形式」段,2026-08-11 實測):
// 1,633 筆新式三元組只有 171 筆填了 library slot,地圖(GET /map)因此幾乎看不到資料。
// 根因是**寫入順序**createRecord 只會替 template.slots_json 裡「已宣告」的 slot 建 entry_value
// (見 src/actions/record-crud.ts createRecord`for (const slot of slots) { if (!(slot in
// input.values)) continue }`——注意是遍歷 template 既有 slots,不是遍歷 caller 傳的 values)。
// 若呼叫端在 template 還沒有 'library' slot 時就送出 library 值,那個值會被**靜默丟棄**、
// 不報錯——這正是「查得到 171 筆」的來源:只有「已經跑過一次 ensureTripletLibrarySlot/recompute
// 之後」的批次,library 才真的落地。
//
// 本檔驗三件事(對應 leo 交辦的三個「要驗的」):
// 1. 源頭:ensure-slot 必須在 write 之前,不能事後補(重現+證明順序才是正解)
// 2. 存量:對一批缺 library 的舊 triplet 補標,前後地圖輸出對照
// 3. 不會重複做:同一批跑兩次,第二次 touch 0 筆
//
// 測試手法沿 library-map.test.ts 慣例:真 node:sqliteNode ≥22.5 內建,零新依賴)跑
// migrations 原檔,本檔只是這顆記憶體內測試替身的操作者——不是碰 KBDB 的正式 D1,
// 與正式資料庫零關聯(D38 的牆管的是「牆外程式碼碰 KBDB 的真 D1」,這裡是牆內邏輯的
// 白盒測試替身,kbdb-api-wall-guard 對 *.test.ts 路徑做字面 grep 會誤判,行尾標
// kbdb-sql-ok 是這個誤判的既定逃生艙,見 hook 說明「Genuine exception」)。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import {
recomputeLibraryMap,
ensureTripletLibrarySlot,
ensureFreshLibraryMaps,
listLibraryMaps,
} from '../src/actions/library-map';
import { createTemplate, createRecord, updateRecord, getRecord, searchByTemplate } from '../src/actions/record-crud';
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok: 記憶體測試替身,非真 KBDB D1
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 灌測試替身 schema,非真 D1
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 同上
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 記憶體測試替身
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 記憶體測試替身
async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 記憶體測試替身
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
// prod 實際 triplet template 的 slotslibrary-map.test.ts 同款常數,2026-07-19 kbdb_list_templates
// 核實)——注意:沒有 library。用它模擬「template 還沒被任何 recompute 摸過」的乾淨起點。
const PROD_TRIPLET_SLOTS = [
'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json',
'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by',
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
];
async function seedTripletTemplate(db: D1Database): Promise<void> {
await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
}
// 對齊 design.md 既有 fallback 語意(source_prefix 參數)的同一條規則:
// library = source_uri 在 '@' 之前的那段(scheme:owner/repo)。這就是本檔+報告裡建議
// kbdb-graph-plugin 在 write 時就该套用的推導規則(見報告,本檔只證明「規則正確、
// 順序對了就能用」,不代表已經改了 kbdb-graph-plugin 的程式碼——那是另一個 repo)。
function deriveLibrary(sourceUri: string): string {
const at = sourceUri.indexOf('@');
return at > 0 ? sourceUri.slice(0, at) : sourceUri;
}
describe('源頭順序 — ensure-slot 必須在 write 之前,事後補救不了已寫的那筆', () => {
it('重現:template 尚無 library slot 時寫入 → library 值被靜默丟棄(不是報錯,是消失)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const rec = await createRecord(db, {
template: 'triplet',
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
owner_id: 'leo',
});
const stored = await getRecord(db, rec.record_id);
// 關鍵斷言:library 完全沒落地,不是空字串、是 undefined(key 都不存在)。
expect(stored!.values.library).toBeUndefined();
expect(stored!.values.source_uri).toBe('gitea:Leo/kb@a.md'); // 其他 slot 正常落地,只有未宣告的 slot 消失
});
it('正解:先 ensureTripletLibrarySlot() 補上 slot,再寫 → library 值正常落地', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const added = await ensureTripletLibrarySlot(db, 'triplet');
expect(added).toBe(true); // 第一次呼叫確實補了 slot
const rec = await createRecord(db, {
template: 'triplet',
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
owner_id: 'leo',
});
const stored = await getRecord(db, rec.record_id);
expect(stored!.values.library).toBe('gitea:Leo/kb');
// 冪等:對已有 slot 的 template 再呼叫一次 → false(不重複加),不影響既有資料。
const addedAgain = await ensureTripletLibrarySlot(db, 'triplet');
expect(addedAgain).toBe(false);
});
});
describe('存量補標 — 對缺 library 的舊 triplet 補標,地圖輸出前後對照', () => {
it('補標前:地圖看不到任何庫(triplet 全部因缺 library 而未被地圖聚合);補標後:庫名正確出現', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
// 模擬現況:三筆舊 triplet,寫入時 template 還沒有 library slot(如實重現存量現況),
// 只帶了 source_uri(之後補標要靠它反推 library)。
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', status: 'active' }, owner_id: 'leo' });
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md', status: 'active' }, owner_id: 'leo' });
const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'github:uncle6-me/notes@c.md', status: 'active' }, owner_id: 'leo' });
// 讀端自動核對重算(M3 收尾機制):這時三筆都缺 library 值。liveTripletCountsByLibrary
// 把「缺值」COALESCE 成 'general' 桶(供 staleness 判斷),但 recomputeLibraryMap 的
// libCond 是 `t.library = 'general'` 精確比對——缺值在底層是 NULL 不是字面 'general'
// 比對不中,實際聚合出 triplet_count:0。**這是本次順手發現的另一個小落差**(live 計數桶
// 與 recompute 精確比對的『general』語意沒對齊,導致這桶每次讀都判 stale、白重算,
// 但至少不會謊報數字)——不在本次任務範圍內(leo 問的是兩代儲存形式的可見性,不是這個
// fallback 桶的效能問題),本測試如實記錄現況,不假裝它是 0。
await ensureFreshLibraryMaps(db, 'leo');
const before = await listLibraryMaps(db, 'leo');
expect(before.length).toBe(1);
expect(before[0].library).toBe('general');
expect(before[0].triplet_count).toBe(0); // 誠實:桶名對得上、數字沒謊報,但也沒把 3 筆算近來(見上註)
// ── 補標(源頭已對:先 ensure slot,才寫值;一次對全部缺值的 record)──
await ensureTripletLibrarySlot(db, 'triplet');
const allTriplets = await searchByTemplate(db, 'triplet', 'leo');
const missing = allTriplets.filter((t) => !t.values.library && t.values.source_uri);
expect(missing.length).toBe(3); // 三筆都缺
for (const t of missing) {
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri) });
}
// 補標後:對每個實際出現的 library 值重算一次地圖(read-path 的 ensureFreshLibraryMaps
// 只認「已知庫名」——entries.metadata.library 或 portal_library;剛補標的三元組 library 值
// 尚未被任何一處登記為「已知庫名」,直接 recompute 該庫最直接、也是 caller 實際會做的事)。
const libs = [...new Set(missing.map((t) => deriveLibrary(t.values.source_uri!)))];
for (const lib of libs) {
await recomputeLibraryMap(db, { library: lib, owner_id: 'leo' });
}
const after = await listLibraryMaps(db, 'leo');
const byLib = new Map(after.map((m) => [m.library, m.triplet_count]));
expect(byLib.get('gitea:Leo/kb')).toBe(2); // r1, r2 同庫 —— 這是 leo 真正要看到的東西
expect(byLib.get('github:uncle6-me/notes')).toBe(1); // r3 另一庫
// 'general' 桶(補標前遺留的空殼,見上一段註)仍在,但 triplet_count 仍是 0——
// 三筆全部被正確歸進真正的庫名,沒有一筆被算進 generalno double counting)。
expect(byLib.get('general')).toBe(0);
expect(after.length).toBe(3);
// 交叉核對:三筆的 library 值確實都落地了(不是只有地圖聚合對,底層資料也對)。
const r1Rec = await getRecord(db, r1.record_id);
const r2Rec = await getRecord(db, r2.record_id);
const r3Rec = await getRecord(db, r3.record_id);
expect(r1Rec!.values.library).toBe('gitea:Leo/kb');
expect(r2Rec!.values.library).toBe('gitea:Leo/kb');
expect(r3Rec!.values.library).toBe('github:uncle6-me/notes');
});
});
describe('不會重複做 — 同一批補標跑兩次,第二次不改動任何東西', () => {
it('第二輪掃描:已有 library 值的 record 一筆都不會被 touch', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'leo' });
// r2 模擬「還沒補標」的舊資料:故意繞過 values 直接不給 librarycreateRecord 這次雖然
// template 已有 slot,但 caller 沒給值 → 該 slot 完全不會被建立 entry_value,等同「缺」)。
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md' }, owner_id: 'leo' });
await updateRecord(db, r1.record_id, { library: deriveLibrary('gitea:Leo/kb@a.md') }); // r1 先補過
async function backfillPass(): Promise<number> {
const all = await searchByTemplate(db, 'triplet', 'leo');
const missing = all.filter((t) => !t.values.library && t.values.source_uri);
for (const t of missing) {
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri!) });
}
return missing.length;
}
const firstPass = await backfillPass();
expect(firstPass).toBe(1); // 只有 r2 被補(r1 已有值,跳過)
const secondPass = await backfillPass();
expect(secondPass).toBe(0); // 第二輪:兩筆都已有 library,一筆都不 touch
// 資料仍然正確、沒被第二輪弄壞。
const r1After = await getRecord(db, r1.record_id);
const r2After = await getRecord(db, r2.record_id);
expect(r1After!.values.library).toBe('gitea:Leo/kb');
expect(r2After!.values.library).toBe('gitea:Leo/kb');
});
});