feat(graph): 收斂 gloss-bridge + owner-threading 為單一可部署分支
合併 arcrun-7-gloss-bridge(A1 gloss 橋)與 owner-threading(owner_id 寫入鏈必經):
- node-persist / triplet-ingest 衝突解為「兩者都要」:persistNodes 既寫 entity
record(帶真 owner)又 upsert gloss entry(帶真 owner、帶 source);ingestEnvelope
既帶 source.uri 又 thread 必填 owner 到底。
- gloss 路徑補 owner 必填(配合 owner-mandatory D28):
* upsertGlossEntry(..., owner_id: string) owner 改必填
* backfillGlossEntries(client, owner_id: string) owner 改必填
* POST /backfill-gloss-entries 缺 owner_id → 400(存量 record owner=None,
owner 由 caller 明確指定,不從 record 帶)
- 合併兩支測試:gloss 測試補 owner 參數以配合 base owner-required mock。
tsc:除 index.ts:57 swaggerUI 既存 baseline 外 0 error。
vitest:34 passed(gloss 7 + owner/graph 27)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ck8bjuBTLFkiFxqLGHMpqG
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// node gloss → embeddable base entry:走 mock KbdbClient(API-as-Wall),零 SQL、不打網路。
|
||||
// 覆蓋:前向 persistNodes 多落 embeddable entry / embed=false 不落 / 空 gloss 不落 / 冪等不重複 /
|
||||
// gloss 變則更新 / backfill 對既有 entity records 補 entry(含冪等、embed=false 略過)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { persistNodes } from '../src/actions/node-persist';
|
||||
import { backfillGlossEntries } from '../src/actions/backfill-gloss-entries';
|
||||
import { NODE_GLOSS_ENTRY_TYPE } from '../src/actions/gloss-entry';
|
||||
import { TPL_ENTITY } from '../src/lib/templates';
|
||||
import { mockClient } from './mock-client';
|
||||
|
||||
const glossEntries = (c: ReturnType<typeof mockClient>) =>
|
||||
c.listEntries({ entry_type: NODE_GLOSS_ENTRY_TYPE });
|
||||
|
||||
describe('persistNodes — 前向:每個 node 另落一筆 embeddable base entry', () => {
|
||||
it('有 gloss 的 node → 落 entry:content=名+gloss、metadata.embed=true、帶 source/node_id', async () => {
|
||||
const c = mockClient();
|
||||
await persistNodes(
|
||||
c,
|
||||
[{ name: '系統動力學', id: 'sd.md', gloss: '用因果圖表解複雜系統的方法論' }],
|
||||
'leo21c',
|
||||
'github:uncle6me-web/wiki@sd.md',
|
||||
);
|
||||
|
||||
const entries = await glossEntries(c);
|
||||
expect(entries.length).toBe(1);
|
||||
const e = entries[0];
|
||||
expect(e.content).toBe('系統動力學:用因果圖表解複雜系統的方法論');
|
||||
expect(e.page_name).toBe('gloss:sd.md');
|
||||
expect(e.owner_id).toBe('leo21c');
|
||||
const meta = JSON.parse(e.metadata_json!);
|
||||
expect(meta.embed).toBe(true); // 關鍵:base embed 模組讀這個
|
||||
expect(meta.source).toBe('github:uncle6me-web/wiki@sd.md');
|
||||
expect(meta.node_id).toBe('sd.md');
|
||||
expect(meta.canonical).toBe('系統動力學');
|
||||
|
||||
// entity record 仍照舊存在(兩者並存:record 給圖、entry 給 embed)。
|
||||
const recs = await c.listRecordsByTemplate(TPL_ENTITY);
|
||||
expect(recs.find((r) => r.values.canonical === '系統動力學')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('embed=false 的 node → 不落 embeddable entry', async () => {
|
||||
const c = mockClient();
|
||||
await persistNodes(c, [{ name: '黃仁勳', id: '黃仁勳', gloss: 'NVIDIA 創辦人', embed: false }], 'leo21c');
|
||||
expect((await glossEntries(c)).length).toBe(0);
|
||||
});
|
||||
|
||||
it('空 gloss 的 node → 不落 embeddable entry(不造無意義空殼)', async () => {
|
||||
const c = mockClient();
|
||||
await persistNodes(c, [{ name: '某卡', id: 'card.md' }], 'leo21c');
|
||||
expect((await glossEntries(c)).length).toBe(0);
|
||||
});
|
||||
|
||||
it('冪等:同一 node 重複 ingest → 只一筆 embeddable entry', async () => {
|
||||
const c = mockClient();
|
||||
const nodes = [{ name: '長壽', id: 'longevity', gloss: '活得更久本身具有價值' }];
|
||||
await persistNodes(c, nodes, 'leo21c', 'u1');
|
||||
await persistNodes(c, nodes, 'leo21c', 'u1'); // 再送一次
|
||||
expect((await glossEntries(c)).length).toBe(1);
|
||||
});
|
||||
|
||||
it('gloss 變更 → 更新既有 entry 內容(仍只一筆)', async () => {
|
||||
const c = mockClient();
|
||||
await persistNodes(c, [{ name: '長壽', id: 'longevity', gloss: '舊描述' }], 'leo21c');
|
||||
await persistNodes(c, [{ name: '長壽', id: 'longevity', gloss: '活得更久本身具有價值' }], 'leo21c');
|
||||
const entries = await glossEntries(c);
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0].content).toBe('長壽:活得更久本身具有價值');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backfillGlossEntries — 存量:對既有 entity records 補 embeddable entry', () => {
|
||||
// 直接以 entity record 造存量(模擬既有 16 筆),不經前向路徑。
|
||||
async function seedEntity(
|
||||
c: ReturnType<typeof mockClient>,
|
||||
v: { canonical: string; node_id?: string; gloss?: string; embed?: string },
|
||||
) {
|
||||
await c.createRecord(TPL_ENTITY, {
|
||||
canonical: v.canonical,
|
||||
node_id: v.node_id ?? '',
|
||||
aliases_json: '[]',
|
||||
entity_type: '',
|
||||
gloss: v.gloss ?? '',
|
||||
embed: v.embed ?? 'true',
|
||||
owner: '', // slot 存量 owner=None(模擬 D28 前的無主 record);backfill 的 owner 由 caller 明確指定
|
||||
}, 'leo21c'); // 記錄層 owner_id:base D28 後 createRecord 一律要 owner;caller 明指
|
||||
}
|
||||
|
||||
it('有 gloss 的既有 record → 落 entry;embed=false / 空 gloss → 略過', async () => {
|
||||
const c = mockClient();
|
||||
await seedEntity(c, { canonical: '長壽', node_id: 'longevity', gloss: '活得更久本身具有價值' });
|
||||
await seedEntity(c, { canonical: '系統動力學', node_id: 'sd', gloss: '用因果圖表解複雜系統的方法論' });
|
||||
await seedEntity(c, { canonical: '無標', node_id: 'x', gloss: '不該嵌', embed: 'false' });
|
||||
await seedEntity(c, { canonical: '空描述', node_id: 'y' }); // 空 gloss
|
||||
|
||||
const res = await backfillGlossEntries(c, 'leo21c');
|
||||
expect(res.scanned).toBe(4);
|
||||
expect(res.created).toBe(2);
|
||||
expect(res.skipped).toBe(2); // embed=false + 空 gloss
|
||||
|
||||
const entries = await glossEntries(c);
|
||||
expect(entries.length).toBe(2);
|
||||
expect(entries.every((e) => JSON.parse(e.metadata_json!).embed === true)).toBe(true);
|
||||
expect(entries.map((e) => e.content).sort()).toEqual(
|
||||
['系統動力學:用因果圖表解複雜系統的方法論', '長壽:活得更久本身具有價值'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('冪等:連跑兩次 backfill → 不造重複(第二次全 unchanged)', async () => {
|
||||
const c = mockClient();
|
||||
await seedEntity(c, { canonical: '長壽', node_id: 'longevity', gloss: '活得更久本身具有價值' });
|
||||
|
||||
const first = await backfillGlossEntries(c, 'leo21c');
|
||||
expect(first.created).toBe(1);
|
||||
|
||||
const second = await backfillGlossEntries(c, 'leo21c');
|
||||
expect(second.created).toBe(0);
|
||||
expect(second.unchanged).toBe(1);
|
||||
expect((await glossEntries(c)).length).toBe(1);
|
||||
});
|
||||
});
|
||||
+16
-2
@@ -18,7 +18,16 @@ export class MockKbdbClient {
|
||||
// 對齊基本盤 D27/D28 即將上線的 owner 必填:缺 owner → 400(模擬 base 擋死無主寫入)。
|
||||
if (!(input?.owner_id ?? '').trim()) throw new Error('[kbdb-base] POST /entries: owner_id required (400)');
|
||||
const id = this.id('entry');
|
||||
const entry: BaseEntry = { id, content: input.content ?? null, entry_type: input.entry_type, owner_id: input.owner_id ?? null };
|
||||
// 對齊 base entry-crud:保留 page_name(idempotency key)、metadata_json(embed 打標)、parent_id。
|
||||
const entry: BaseEntry = {
|
||||
id,
|
||||
content: input.content ?? null,
|
||||
entry_type: input.entry_type,
|
||||
owner_id: input.owner_id ?? null,
|
||||
parent_id: input.parent_id ?? null,
|
||||
page_name: input.page_name ?? null,
|
||||
metadata_json: input.metadata_json ?? null,
|
||||
};
|
||||
this.entries.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
@@ -28,8 +37,13 @@ export class MockKbdbClient {
|
||||
}
|
||||
|
||||
async listEntries(filters: any = {}): Promise<BaseEntry[]> {
|
||||
// 對齊 base entry-crud listEntries 的 exact-match filters(含 page_name idempotency key、parent_id)。
|
||||
return [...this.entries.values()].filter(
|
||||
(e) => (!filters.entry_type || e.entry_type === filters.entry_type) && (!filters.owner_id || e.owner_id === filters.owner_id),
|
||||
(e) =>
|
||||
(!filters.entry_type || e.entry_type === filters.entry_type) &&
|
||||
(!filters.owner_id || e.owner_id === filters.owner_id) &&
|
||||
(!filters.parent_id || e.parent_id === filters.parent_id) &&
|
||||
(!filters.page_name || e.page_name === filters.page_name),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user