Files
Arcrun/kbdb/tests/library-map.test.ts
T
uncle6me-web 614fe44812 fix(kbdb): 藏書地圖加 entry_count——triplet_count=0 不再被誤讀成「沒有知識」(Arcrun#87 三次收尾)
leo21c 實測:kbdb_get_map() 9 庫裡 8 庫 triplet_count:0/top_entities:[](kb 以外全部),
一個全新 session 讀到這份地圖會合理但錯誤地判定「這些庫沒有知識」而放棄查詢——但
kbdb_search(mode=keyword) 找得到 42 筆真內容(來源 gitea:Leo/Arcrun@…/gitea:Leo/mira@…等),
其中 15 筆 entries 的 metadata_json.library 已正確標成 arcrun/mira/arcrun-harness/arcrun-rag。

查證(唯讀,未動 leo21c 任何寫入):
- entries(原始 ingest 內容)與 triplet(從 entries 萃取出的三元組)是兩個不同存放處。
- kb 以外 7 庫:entries 有、library 標記正確;triplet 一筆都沒有——不是「三元組沒貼標」,
  是「三元組從沒被萃取」(另一條偵察線在查斷在哪一段,跨 repo,本 PR 不處理)。
- general 庫(未標 library 的三元組兜底分類)即時計數也是 0 ⇒ 沒有任何「有三元組但沒標庫」
  的候選 ⇒ Leo/Arcrun#87 先前那條批次補標通道(PR #114)在目前資料現況下會補到 0 筆,
  它解的是另一個問題,不是這次「地圖看起來是空的」的真因。

本次修法(純讀端加欄位,不碰任何寫入/部署/線上資源):
- kbdb/src/actions/library-map.ts:新增 liveEntryCountsByLibrary(),依 entries 自己的
  metadata_json.$.library 分組即時計數(排除 entry_type='value' 儲存碎片與地圖自己的歷史
  摘要 block,避免自我膨脹)。listLibraryMaps/getLibraryMapDetail/recomputeLibraryMap
  的回傳都加上 entry_count,與 triplet_count 並排、互不覆蓋。
- mcp/src/lib/library-map.ts:renderLibraryMapLines(MCP 連線開場注入的那份地圖原文)
  triplet_count=0 但 entry_count>0 時改印「0 triplets/N 筆原始內容(尚未萃取關係,
  kbdb_search 查得到)」,不再只印「0 triplets」。
- mcp/src/tools/kbdb_map.ts:kbdb_get_map 工具的全館/單庫回應都帶 entry_count,並在符合
  條件時附加提示,明講「triplet_count=0 不代表沒有知識」。
- console-ui/public/console/index.html:藏書地圖看板卡片同步顯示,人類看的畫面同一件事。

順手修掉一個真 SQL bug:entry_count 排除條件原寫
`NOT (entry_type='block' AND json_extract(...)='library_map')`,SQL 三值邏輯下
metadata_json 沒有 $.kind 欄位時 json_extract 回 NULL、`NULL = 'library_map'` 為 NULL
(非 false),整條 WHERE 判定 NULL 而把所有列濾掉——改用 COALESCE(...,'') 修正
(新增測試以 sqlite 實跑驗證抓到並鎖住這個修法)。

測試:kbdb 21/21(新增 2 案,全套 215/215);mcp kbdb-map 33/33(新增 7 案,全套 120/120)。
cypher-executor 既有 map/portal-data 相關測試(library-map-scope-108/kbdb-map-proxy/
portal-data)103/104 綠,唯一失敗(/portal HTML 殼 404)在未動過的 main checkout 上同樣
失敗,環境既有問題、與本次改動無關。

CP:◐ 半通。程式碼在此分支,測試綠燈,未併 main、未部署 leo21c——entry_count 要讓
leo21c 的真實使用者看到,需部署 kbdb+mcp(cypher-executor 未改動,portal-data.ts
是透明轉發不需重部署)。三元組萃取斷在哪一段(真因)與 23/42 entries 未 embed
(語意搜尋覆蓋率)兩件不在本 PR 範圍,分別交給另一條偵察線與 embed reconcile 管線。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:36:03 +08:00

442 lines
24 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-map(藏書地圖)M1+M2 — SDD system-dev/docs/3-specs/library-map(源頭 Arcrun#39)。
// 測試策略:聚合 SQLdegree 排序/predicate 統計/跨庫 joinsupersede 查找)用「真 SQLite」驗——
// node:sqliteNode ≥22.5 內建,零新依賴)跑 migrations/0001+0003 原檔,比 capture-DB 只驗 SQL
// 形狀更硬;route 行為(參數解析/400/404/回應形狀)走 Hono app.request,與既有測試同款。
// 註:D1 語意與 SQLite 幾乎同源,僅 session/consistency 層不同——本測試覆蓋的純 SQL 聚合在兩邊等價。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { Hono } from 'hono';
import { mapRoutes } from '../src/routes/map';
import {
recomputeLibraryMap,
listLibraryMaps,
getLibraryMapDetail,
ensureTripletLibrarySlot,
ensureFreshLibraryMaps,
LIBRARY_MAP_SLOTS,
} from '../src/actions/library-map';
import { createTemplate, createRecord, getRecord, getTemplate, searchByTemplate } from '../src/actions/record-crud';
import { createEntry } from '../src/actions/entry-crud';
import type { Bindings } from '../src/types';
// ── node:sqlite → D1 介面最小 adapterprepare/bind/all/first/run,本 codebase 只用這些)──
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8'));
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[] }; },
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; },
async run() { raw.prepare(sql).run(...params); return { success: true }; },
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
// prod 實際 triplet template 的 slots2026-07-19 kbdb_list_templates 核實)——注意:沒有 library。
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' });
}
async function seedTriplet(
db: D1Database,
v: { s: string; p: string; o: string; library?: string; source_uri?: string; status?: string },
owner = 'leo',
): Promise<string> {
const values: Record<string, string> = { subject: v.s, predicate: v.p, object: v.o };
if (v.library) values.library = v.library;
if (v.source_uri) values.source_uri = v.source_uri;
if (v.status) values.status = v.status;
const rec = await createRecord(db, { template: 'triplet', values, owner_id: owner });
return rec.record_id;
}
function makeApp(db: D1Database) {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/map', mapRoutes);
const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings;
return { app, env };
}
describe('M1 — library_map templatetriplet library slot(真 SQLite', () => {
it('migration 0003 seedlibrary_map template 落在 templates 表、slots 齊全(D6 零建表)', async () => {
const db = makeSqliteD1();
const tpl = await getTemplate(db, 'library_map');
expect(tpl).not.toBeNull();
expect(JSON.parse(tpl!.slots_json)).toEqual(LIBRARY_MAP_SLOTS);
});
it('ensureTripletLibrarySlotprod 形狀(無 library slot)→ 補上;再跑冪等 false', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
expect(await ensureTripletLibrarySlot(db, 'triplet')).toBe(true);
const tpl = await getTemplate(db, 'triplet');
expect(JSON.parse(tpl!.slots_json)).toContain('library');
expect(await ensureTripletLibrarySlot(db, 'triplet')).toBe(false); // 只增不減、冪等
});
it('recompute 對缺 triplet template 的 DB → 誠實丟錯(不假裝算出空地圖)', async () => {
const db = makeSqliteD1();
await expect(recomputeLibraryMap(db, { library: 'kb' })).rejects.toThrow('triplet template not found');
});
});
describe('M2 — recompute 聚合(真 SQLite 實跑 SQL', () => {
async function seedKbLibrary(db: D1Database) {
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// kb 庫:A 出現 3 次(degree 3)、B 2 次、C 1 次;predicate 連結至×2、屬於×1
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
await seedTriplet(db, { s: 'B', p: '屬於', o: 'A', library: 'kb' });
}
it('top_entities degree 排序+relation_profiletriplet_countcontent 可嵌人話', async () => {
const db = makeSqliteD1();
await seedKbLibrary(db);
const r = await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫卡片', commit_hash: 'abc123' });
expect(r.map.triplet_count).toBe(3);
expect(r.map.top_entities).toEqual([
{ name: 'A', degree: 3 },
{ name: 'B', degree: 2 },
{ name: 'C', degree: 1 },
]);
expect(r.map.relation_profile).toEqual([
{ predicate: '連結至', count: 2 },
{ predicate: '屬於', count: 1 },
]);
// design §5content = `{library}{narrative}。核心:{top 前幾個}`M6 semantic 路由直接嵌)
expect(r.map.content).toBe('kb:知識庫卡片。核心:A、B、C');
expect(r.map.commit_hash).toBe('abc123');
expect(r.superseded).toEqual([]);
expect(r.triplet_library_slot_added).toBe(false); // seed 已補過 slot → recompute 冪等回 false
});
it('superseded triplet 不入地圖(COALESCE status active-only', async () => {
const db = makeSqliteD1();
await seedKbLibrary(db);
await seedTriplet(db, { s: 'X', p: '連結至', o: 'Y', library: 'kb', status: 'superseded' });
const r = await recomputeLibraryMap(db, { library: 'kb' });
expect(r.map.triplet_count).toBe(3);
expect(r.map.top_entities.map((t) => t.name)).not.toContain('X');
});
it('source_prefix fallback:舊 triplet(無 library 值)靠 source_uri 前綴歸庫;不帶 fallback 則不計', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
// 模擬 prod 現況:triplet 只有 source_urigitea:Leo/kb@…),沒有 library slot 值
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', source_uri: 'gitea:Leo/kb@system-dev/wiki/cards/kb/00-INDEX.md#seg02' });
await seedTriplet(db, { s: 'B', p: '連結至', o: 'C', source_uri: 'gitea:Leo/kb@system-dev/wiki/cards/kb/x.md' });
await seedTriplet(db, { s: 'D', p: '連結至', o: 'E', source_uri: 'gitea:Leo/notes@cards/y.md' }); // 他庫
const strict = await recomputeLibraryMap(db, { library: 'kb' });
expect(strict.map.triplet_count).toBe(0); // library slot 全空 → 嚴格模式誠實回 0
const fb = await recomputeLibraryMap(db, { library: 'kb', source_prefix: 'gitea:Leo/kb@' });
expect(fb.map.triplet_count).toBe(2);
expect(fb.map.top_entities.map((t) => t.name)).toEqual(['B', 'A', 'C']);
});
it('bridges:本庫 entity 出現在其他庫(library slot 標記側)→ 跨庫 join 列出', async () => {
const db = makeSqliteD1();
await seedKbLibrary(db);
await seedTriplet(db, { s: 'A', p: '參與', o: 'Z', library: 'notes' }); // A 橫跨 kb/notes
const r = await recomputeLibraryMap(db, { library: 'kb' });
expect(r.map.bridges).toEqual([{ entity: 'A', libraries: ['notes'] }]);
});
it('owner 隔離:帶 owner_id 只聚合該 owner 的 triplet(照 base 既有 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 r = await recomputeLibraryMap(db, { library: 'kb', owner_id: 'tenant1' });
expect(r.map.triplet_count).toBe(1);
expect(r.map.top_entities.map((t) => t.name).sort()).toEqual(['A', 'B']);
});
it('supersede 順序安全:重算兩次 → 舊 map 標 superseded、讀端只見最新 active', async () => {
const db = makeSqliteD1();
await seedKbLibrary(db);
const first = await recomputeLibraryMap(db, { library: 'kb', narrative: '第一版' });
await seedTriplet(db, { s: 'A', p: '連結至', o: 'D', library: 'kb' });
const second = await recomputeLibraryMap(db, { library: 'kb', narrative: '第二版' });
expect(second.superseded).toEqual([first.map.record_id]); // 舊 active 被點名
const oldRec = await getRecord(db, first.map.record_id);
expect(oldRec!.values.status).toBe('superseded'); // 沿用既有 status slot 語意(R6
const detail = await getLibraryMapDetail(db, 'kb');
expect(detail!.record_id).toBe(second.map.record_id);
expect(detail!.narrative).toBe('第二版');
expect(detail!.triplet_count).toBe(4);
// 全館視圖同樣只剩一行 kb(superseded 不重複出現)
const all = await listLibraryMaps(db);
expect(all.filter((r) => r.library === 'kb')).toHaveLength(1);
});
});
describe('M2 — route 行為(GET /map、GET /map/:library、POST /map/recompute', () => {
it('POST /map/recompute 缺 library → 400query 傳 librarybody 傳 narrative 可重算', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const { app, env } = makeApp(db);
const bad = await app.request('/map/recompute', { method: 'POST' }, env);
expect(bad.status).toBe(400);
const ok = await app.request('/map/recompute?library=kb', {
method: 'POST',
body: JSON.stringify({ narrative: '知識庫' }),
headers: { 'content-type': 'application/json' },
}, env);
expect(ok.status).toBe(200);
const body = (await ok.json()) as { success: boolean; map: { library: string; content: string } };
expect(body.success).toBe(true);
expect(body.map.library).toBe('kb');
expect(body.map.content).toContain('kb:知識庫');
});
it('GET /map:從未 recomputetemplate 不存在)→ 誠實空清單;有資料 → 每庫一行、top 3 名字', async () => {
const empty = makeSqliteD1();
// 空 DB 連 library_map template 都拿掉,模擬「migration 未跑、也從未 recompute」的自架環境
await (empty as unknown as { prepare(sql: string): { run(): Promise<unknown> } })
.prepare("DELETE FROM templates WHERE name = 'library_map'").run();
const e = makeApp(empty);
const r0 = await e.app.request('/map', {}, e.env);
expect(r0.status).toBe(200);
expect(await r0.json()).toEqual({ success: true, libraries: [], count: 0 });
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// kb 庫塞 4 個 entities(驗 top_entities 全館視圖只留 3
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
await seedTriplet(db, { s: 'A', p: '連結至', o: 'D', library: 'kb' });
await seedTriplet(db, { s: 'X', p: '參與', o: 'Y', library: 'notes' });
await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫' });
await recomputeLibraryMap(db, { library: 'notes', narrative: '隨手筆記' });
const { app, env } = makeApp(db);
const res = await app.request('/map', {}, env);
const body = (await res.json()) as { libraries: { library: string; narrative: string; top_entities: string[]; triplet_count: number }[]; count: number };
expect(body.count).toBe(2);
const kb = body.libraries.find((l) => l.library === 'kb')!;
expect(kb.narrative).toBe('知識庫');
expect(kb.triplet_count).toBe(3);
expect(kb.top_entities).toEqual(['A', 'B', 'C']); // 每庫一行只留 top 3R3 數百 token 內)
});
it('GET /map/:library:完整 slotscontent;未知庫 404', 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: '知識庫', commit_hash: 'deadbeef' });
const { app, env } = makeApp(db);
const res = await app.request('/map/kb', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { map: Record<string, unknown> };
expect(body.map.library).toBe('kb');
expect(body.map.commit_hash).toBe('deadbeef');
expect(body.map.status).toBe('active');
expect(Array.isArray(body.map.relation_profile)).toBe(true);
expect(Array.isArray(body.map.bridges)).toBe(true);
expect(body.map.content).toBe('kb:知識庫。核心:A、B');
const miss = await app.request('/map/nope', {}, env);
expect(miss.status).toBe(404);
});
});
// 2026-08-08M3 收尾——真因是「等外部呼叫 /map/recompute」這條線三週沒人接(總管實測 grep
// 全 repo 查無呼叫點),沒手動 backfill 過的租戶恆空。修法:讀端自己核對即時三元組數,落差
// 就地補算,不再依賴任何外部呼叫者。以下驗證這條「即時新鮮度」機制本身。
describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動核對重算,不靠外部呼叫 recompute', () => {
it('從未手動呼過 recomputeGET /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('Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次觸發重算(不再無止盡寫入)', async () => {
// 重現票上的根因:liveTripletCountsByLibrary 原本不濾 statusrecomputeLibraryMap 只算
// active——只要庫裡混了 superseded triplet,兩邊算出來的數字永遠對不上,
// ensureFreshLibraryMaps 就永遠判定 stale,每次讀地圖都重算、每次都新建一筆 record。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); // active
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb', status: 'superseded' }); // 已淘汰
const { app, env } = makeApp(db);
// 第一次讀:資料是新的(從沒 recompute 過),觸發一次重算是正常的。
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); // 只算 active 那筆
const countAfterFirst = (await searchByTemplate(db, 'library_map')).length;
// 第二次讀:中間沒有任何寫入動作。修好之前,這裡會再次判定 stale 並多新建一筆 record。
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(1);
const countAfterSecond = (await searchByTemplate(db, 'library_map')).length;
expect(countAfterSecond).toBe(countAfterFirst); // 沒有新增任何 library_map record
// 第三次也一樣,多讀幾次確認不是巧合。
await app.request('/map', {}, env);
const countAfterThird = (await searchByTemplate(db, 'library_map')).length;
expect(countAfterThird).toBe(countAfterFirst);
});
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「已知但目前是空庫」(200triplet_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('entry_count 讓「有原始內容但三元組從沒萃取過」與「真的什麼都沒有」分得清(Arcrun#87 三次收尾)', async () => {
// 情境沿用上一案的 'hr' 庫(entries 蓋過章、triplet_count:0),這正是 leo21c 實測到的真實
// 現況(kb 以外 7 庫皆此況):地圖過去只回 triplet_count,AI 讀到 0 就誤判「這庫沒有知識」,
// 但實際上 kbdb_search 找得到內容——因為 entries 一直都在,只是沒被萃取成三元組。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await createEntry(db, {
content: '人資資料 A',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
await createEntry(db, {
content: '人資資料 B',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
const { app, env } = makeApp(db);
// 單庫詳圖:triplet_count 仍是 0(沒騙這件事),但 entry_count 誠實回 2。
const detailRes = await app.request('/map/hr?owner_id=leo', {}, env);
const detailBody = (await detailRes.json()) as { map: { triplet_count: number; entry_count: number } };
expect(detailBody.map.triplet_count).toBe(0);
expect(detailBody.map.entry_count).toBe(2);
// 全館視圖:同一個庫、同一組數字,兩個 consumerGET /map、GET /map/:library)不能對不上。
const listRes = await app.request('/map?owner_id=leo', {}, env);
const listBody = (await listRes.json()) as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
const hr = listBody.libraries.find((l) => l.library === 'hr');
expect(hr).toBeDefined();
expect(hr!.triplet_count).toBe(0);
expect(hr!.entry_count).toBe(2);
});
it('entry_count 的計數口徑排除 value 型 entries 與地圖自己的歷史摘要 block(不自我膨脹)', async () => {
// record-crud 建 triplet record 時,每個 slot 值都會落一筆 entry_type='value' 的 entry
// (儲存實作細節,不是「一筆知識」);地圖 recompute 也會建 entry_type='block' 且
// metadata.kind='library_map' 的摘要 entry——兩者都不該被算進 entry_count,否則地圖會把
// 自己的內部管線雜訊當成「使用者知識」回報,數字沒有意義。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// 建一條 kb 三元組 → 連帶產生數筆 entry_type='value' 的 entries(不該被算進 entry_count)。
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
// 觸發一次 recompute → 產生一筆 entry_type='block'/metadata.kind='library_map' 的摘要 entry
// (同樣不該被算進 entry_count)。
await recomputeLibraryMap(db, { library: 'kb' });
const detail = await getLibraryMapDetail(db, 'kb');
// 這個情境下 kb 沒有任何「真的 ingest 進來的原始內容」entry(只有 value 碎片與地圖自己的摘要),
// entry_count 應誠實回 0——不能因為底層 entries 表其實有好幾筆就報出一個誤導的非零數字。
expect(detail!.entry_count).toBe(0);
expect(detail!.triplet_count).toBe(1); // 對照:triplet_count 不受這個排除規則影響,維持原樣。
});
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:只跑過 migrationslibrary_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 });
});
});