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:
Claude
2026-07-05 09:16:26 +00:00
parent b53c5c94a9
commit 4cc7057cea
8 changed files with 270 additions and 3 deletions
+120
View File
@@ -0,0 +1,120 @@
// node gloss → embeddable base entry:走 mock KbdbClientAPI-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 → 落 entrycontent=名+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 → 落 entryembed=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
View File
@@ -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_nameidempotency key)、metadata_jsonembed 打標)、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),
);
}