614fe44812
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>
442 lines
24 KiB
TypeScript
442 lines
24 KiB
TypeScript
// library-map(藏書地圖)M1+M2 — SDD system-dev/docs/3-specs/library-map(源頭 Arcrun#39)。
|
||
// 測試策略:聚合 SQL(degree 排序/predicate 統計/跨庫 join/supersede 查找)用「真 SQLite」驗——
|
||
// node:sqlite(Node ≥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 介面最小 adapter(prepare/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 的 slots(2026-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 template+triplet library slot(真 SQLite)', () => {
|
||
it('migration 0003 seed:library_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('ensureTripletLibrarySlot:prod 形狀(無 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_profile+triplet_count+content 可嵌人話', 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 §5:content = `{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_uri(gitea: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 → 400;query 傳 library+body 傳 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:從未 recompute(template 不存在)→ 誠實空清單;有資料 → 每庫一行、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 3(R3 數百 token 內)
|
||
});
|
||
|
||
it('GET /map/:library:完整 slots+content;未知庫 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-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('Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次觸發重算(不再無止盡寫入)', async () => {
|
||
// 重現票上的根因:liveTripletCountsByLibrary 原本不濾 status,recomputeLibraryMap 只算
|
||
// 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「已知但目前是空庫」(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('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);
|
||
|
||
// 全館視圖:同一個庫、同一組數字,兩個 consumer(GET /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:只跑過 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 });
|
||
});
|
||
});
|