1 Commits

Author SHA1 Message Date
Claude 4cc7057cea 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 冪等
2026-07-05 09:16:26 +00:00
8 changed files with 270 additions and 3 deletions
+41
View File
@@ -0,0 +1,41 @@
// 存量回填:對「已存在的 entity records」補上對應的 embeddable base entry。
// 讀 base 既有 entity recordsGET /records/by-template/entity)→ 為每筆有 gloss 的落一筆 embeddable entry
// (同 persistNodes 前向路徑的格式與冪等)。讓既有知識立刻可被 base embed backfill 嵌到。
// 鐵律:走 base APIAPI-as-Wall)、零 SQL、樂高法(<100 行、一檔一事、第一參數收 KbdbClient)。
import type { KbdbClient } from '../lib/kbdb-client';
import { TPL_ENTITY, ensurePluginTemplates } from '../lib/templates';
import { upsertGlossEntry, type GlossEntryOutcome } from './gloss-entry';
export type BackfillGlossResult = {
scanned: number; // 掃到的 entity record 數
created: number; // 新落的 embeddable entry 數
updated: number; // gloss 變 → 更新內容的數
unchanged: number; // 已存在且內容相同(冪等 no-op)
skipped: number; // embed=false / 空 gloss / 空 canonical → 不落
};
/**
* 對既有 entity records 補 embeddable entry。冪等:重跑不造重複(同 node → 同 page_name → no-op)。
* embed 標為 'false' 的 record 略過(明確不入向量庫)。source 未知(存量)→ 留空。
*/
export async function backfillGlossEntries(
client: KbdbClient,
owner_id?: string,
): Promise<BackfillGlossResult> {
await ensurePluginTemplates(client);
const records = await client.listRecordsByTemplate(TPL_ENTITY, owner_id);
const res: BackfillGlossResult = { scanned: records.length, created: 0, updated: 0, unchanged: 0, skipped: 0 };
for (const r of records) {
const v = r.values;
if (v.embed === 'false') { res.skipped++; continue; } // 明確標不嵌 → 略過
const outcome: GlossEntryOutcome = await upsertGlossEntry(
client,
{ canonical: v.canonical ?? '', node_id: v.node_id || '', gloss: v.gloss ?? '', source: '' },
owner_id,
);
res[outcome]++; // 'created' | 'updated' | 'unchanged' | 'skipped' 都是 res 的 number 欄位
}
return res;
}
+66
View File
@@ -0,0 +1,66 @@
// node gloss → base embeddable entry。把 node 的 canonical+gloss 落成一筆 base entry
// metadata.embed=true,供 base optional embed 模組讀標 → 嵌進 Vectorize(見 arcrun/kbdb src/embed.ts)。
// 鐵律:走 base APIAPI-as-Wall)、零 SQL、不綁 Vectorizeembedding 是 base 職責,graph 只落標)。
//
// 為何要這檔:node 打標存進 entity **record**node-persist),但 base embed 只掃 **entries**.metadata_json
// .$.embed===true → 標在 record、讀在 entry 對不上,語意查不到圖內容。這裡補「同一 gloss 也落成 entry」。
//
// 冪等(base 無 upsert entry 端點):以 node 去重鍵派生確定性 page_namebase 文件化的 idempotency key),
// 先 listEntries({entry_type,page_name}) 查存 → 無則 create、內容變則 patch、同則 no-op。
// [→arcrun] 若 base 日後補「POST /entries upsertpage_name 為鍵)」,這裡可收斂成單一呼叫、免 list-then-write。
import type { KbdbClient } from '../lib/kbdb-client';
// base embed 對「內容語意」無知,只認通用 embed 旗標;entry_type 供向量 metadata 過濾/辨識這批是圖節點 gloss。
export const NODE_GLOSS_ENTRY_TYPE = 'graph_node_gloss';
export type GlossEntryInput = {
canonical: string;
node_id?: string;
gloss?: string;
source?: string; // 沿用 node 的 ingest 來源(envelope source.uri);存量回填未知則留空
};
export type GlossEntryOutcome = 'created' | 'updated' | 'unchanged' | 'skipped';
/** node 去重鍵(同 persistNodesid 優先、無則 canonical)→ 確定性 idempotency key。 */
function glossKey(canonical: string, node_id?: string): string {
return `gloss:${(node_id || canonical).toLowerCase().trim()}`;
}
/**
* 把一個 node 的 gloss 落成 embeddable base entry(冪等)。
* - 空 gloss(或空 canonical)→ 跳過:base 對空 content 本就跳過 embed,且裸名的語意召回價值低、
* keyword 搜尋已覆蓋 → 不造無意義 entry(契約允許「空 gloss 可跳過」)。
* - content = `canonicalgloss`(名+描述一起 embed,利於語意召回)。
* - metadata_json.embed=truebase 讀此旗標)、source、node_id、canonical(回連原 node)。
*/
export async function upsertGlossEntry(
client: KbdbClient,
node: GlossEntryInput,
owner_id?: string,
): Promise<GlossEntryOutcome> {
const canonical = (node.canonical || '').trim();
const gloss = (node.gloss || '').trim();
if (!canonical || !gloss) return 'skipped';
const page_name = glossKey(canonical, node.node_id);
const content = `${canonical}${gloss}`;
const metadata_json = JSON.stringify({
embed: true,
source: node.source || '',
node_id: node.node_id || '',
canonical,
});
// 確定性 key 去重:同一 node 重複 ingest 不造重複 entry。
const existing = await client.listEntries({ entry_type: NODE_GLOSS_ENTRY_TYPE, page_name, owner_id });
if (existing.length > 0) {
const e = existing[0];
if (e.content === content) return 'unchanged'; // 同內容 → no-op
await client.updateEntry(e.id, { content, metadata_json }); // gloss 變 → 更新(base PATCH 觸發重嵌)
return 'updated';
}
await client.createEntry({ content, entry_type: NODE_GLOSS_ENTRY_TYPE, owner_id, page_name, metadata_json });
return 'created';
}
+9
View File
@@ -4,6 +4,7 @@
import type { KbdbClient } from '../lib/kbdb-client';
import { TPL_ENTITY, ensurePluginTemplates } from '../lib/templates';
import { upsertGlossEntry } from './gloss-entry';
export type IngestNode = {
name: string;
@@ -23,6 +24,7 @@ export async function persistNodes(
client: KbdbClient,
nodes: IngestNode[],
owner_id?: string,
source?: string, // envelope source.uri,帶進 gloss entry 的 metadata.source(供 base backfill 依 source 過濾)
): Promise<void> {
if (!nodes || nodes.length === 0) return;
await ensurePluginTemplates(client);
@@ -46,5 +48,12 @@ export async function persistNodes(
},
owner_id,
);
// 另落一筆 embeddable base entrymetadata.embed=true)——record 的 gloss 標 base embed 讀不到,
// 必須也落成 entry base embed 模組才掃得到(打標≠讀標的修補)。只在要 embed(embed !== false)時落;
// 空 gloss 由 upsertGlossEntry 自行跳過。冪等:同 node 重複 ingest 不造重複 entry。
if (n.embed !== false) {
await upsertGlossEntry(client, { canonical: n.name, node_id: n.id, gloss: n.gloss, source }, owner_id);
}
}
}
+2 -1
View File
@@ -84,7 +84,8 @@ export async function ingestEnvelope(
// 1b) 落地 node 層打標(embed / gloss / aliases),供 base embed 模組讀標執行 embedding。
// graph 自己不算向量(鐵律一致)。id 作去重鍵:同一卡(同 id/檔名)只存一筆 entity,不以邊數重複。
await persistNodes(client, env.nodes ?? [], owner_id);
// 並為每個 node 另落一筆 embeddable base entrysource.uri 帶進 metadata.source)。
await persistNodes(client, env.nodes ?? [], owner_id, env.source.uri);
// 2) 後翻舊批 status=deprecated(指向本批 source_uriappend 在前 → 無空窗)。
for (const old of priorActive) {
+6
View File
@@ -13,6 +13,9 @@ export type BaseEntry = {
owner_id: string | null;
parent_id?: string | null;
page_name?: string | null;
// base entries 的通用 metadataTEXTJSON 字串)。base embed 模組讀 metadata_json.$.embed / $.source
// 決定要不要嵌、嵌進哪個 source 分組(見 arcrun/kbdb src/embed.ts)。插件只落標、不算向量。
metadata_json?: string | null;
created_at?: number;
updated_at?: number;
};
@@ -28,7 +31,10 @@ export type CreateEntryInput = {
entry_type: string;
owner_id?: string;
parent_id?: string;
// page_name = base 文件化的 idempotency keyentry-crud.tsexact-match lookup)。
page_name?: string;
// metadata_json = base 既有欄位(JSON 字串)。標 { embed:true, source, ... } 供 base embed 模組讀標執行。
metadata_json?: string;
};
/** 基本盤 API client。所有方法 = 一個 HTTP 呼叫,零 SQL。 */
+10
View File
@@ -6,6 +6,7 @@ import {
rejectPendingAlias,
} from '../actions/entity-pending';
import { listTripletEntities } from '../actions/triplet-entities';
import { backfillGlossEntries } from '../actions/backfill-gloss-entries';
import { makeKbdbClient } from '../lib/kbdb-client';
const entityRoutes = new OpenAPIHono<{ Bindings: Bindings }>();
@@ -64,4 +65,13 @@ entityRoutes.post('/pending/:id/reject', async (c) => {
return c.json({ success: true, action: 'rejected', newEntity });
});
// POST /backfill-gloss-entries — 存量回填:對既有 entity records 補 embeddable base entrymetadata.embed=true)。
// route 只驗參數 + 呼叫 action(樂高法:無業務邏輯)。冪等,可重複呼叫。
entityRoutes.post('/backfill-gloss-entries', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { owner_id?: string };
const owner_id = c.req.query('owner_id') || body?.owner_id || undefined;
const result = await backfillGlossEntries(makeKbdbClient(c.env), owner_id);
return c.json({ success: true, ...result });
});
export { entityRoutes };
+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),
);
}