feat(graph): node gloss→base embeddable entry 橋——Arcrun#7 A1
修「打標≠讀標」:node gloss+embed 標在 entity record、但 base embed 只掃 entries.metadata.embed → persistNodes 另落一筆 content=canonical:gloss、metadata.embed=true 的 base entry(走 API/零SQL)。 - gloss-entry.ts: upsertGlossEntry 冪等(確定性 page_name + list-then-write) - backfill-gloss-entries.ts + route: 對既有 entity record 補 gloss entry - triplet-ingest 帶 source.uri 進 metadata;kbdb-client 暴露 metadata_json(base 早支援) - vitest 30/30(+7);[→arcrun] base 缺 upsert entry 端點,暫以 list-then-write 冪等
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 }]);
|
||||
expect((await glossEntries(c)).length).toBe(0);
|
||||
});
|
||||
|
||||
it('空 gloss 的 node → 不落 embeddable entry(不造無意義空殼)', async () => {
|
||||
const c = mockClient();
|
||||
await persistNodes(c, [{ name: '某卡', id: 'card.md' }]);
|
||||
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: '',
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
expect(first.created).toBe(1);
|
||||
|
||||
const second = await backfillGlossEntries(c);
|
||||
expect(second.created).toBe(0);
|
||||
expect(second.unchanged).toBe(1);
|
||||
expect((await glossEntries(c)).length).toBe(1);
|
||||
});
|
||||
});
|
||||
+16
-2
@@ -16,7 +16,16 @@ export class MockKbdbClient {
|
||||
|
||||
async createEntry(input: any): Promise<BaseEntry> {
|
||||
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;
|
||||
}
|
||||
@@ -26,8 +35,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