From 4cc7057cead15e05e458bbfbcdceb74e47850d10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 09:16:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(graph):=20node=20gloss=E2=86=92base=20embe?= =?UTF-8?q?ddable=20entry=20=E6=A9=8B=E2=80=94=E2=80=94Arcrun#7=20A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修「打標≠讀標」: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 冪等 --- src/actions/backfill-gloss-entries.ts | 41 +++++++++ src/actions/gloss-entry.ts | 66 ++++++++++++++ src/actions/node-persist.ts | 9 ++ src/actions/triplet-ingest.ts | 3 +- src/lib/kbdb-client.ts | 6 ++ src/routes/entities.ts | 10 +++ tests/gloss-entry.test.ts | 120 ++++++++++++++++++++++++++ tests/mock-client.ts | 18 +++- 8 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 src/actions/backfill-gloss-entries.ts create mode 100644 src/actions/gloss-entry.ts create mode 100644 tests/gloss-entry.test.ts diff --git a/src/actions/backfill-gloss-entries.ts b/src/actions/backfill-gloss-entries.ts new file mode 100644 index 0000000..258dc9c --- /dev/null +++ b/src/actions/backfill-gloss-entries.ts @@ -0,0 +1,41 @@ +// 存量回填:對「已存在的 entity records」補上對應的 embeddable base entry。 +// 讀 base 既有 entity records(GET /records/by-template/entity)→ 為每筆有 gloss 的落一筆 embeddable entry +// (同 persistNodes 前向路徑的格式與冪等)。讓既有知識立刻可被 base embed backfill 嵌到。 +// 鐵律:走 base API(API-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 { + 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; +} diff --git a/src/actions/gloss-entry.ts b/src/actions/gloss-entry.ts new file mode 100644 index 0000000..25b3f1d --- /dev/null +++ b/src/actions/gloss-entry.ts @@ -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 API(API-as-Wall)、零 SQL、不綁 Vectorize(embedding 是 base 職責,graph 只落標)。 +// +// 為何要這檔:node 打標存進 entity **record**(node-persist),但 base embed 只掃 **entries**.metadata_json +// .$.embed===true → 標在 record、讀在 entry 對不上,語意查不到圖內容。這裡補「同一 gloss 也落成 entry」。 +// +// 冪等(base 無 upsert entry 端點):以 node 去重鍵派生確定性 page_name(base 文件化的 idempotency key), +// 先 listEntries({entry_type,page_name}) 查存 → 無則 create、內容變則 patch、同則 no-op。 +// [→arcrun] 若 base 日後補「POST /entries upsert(page_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 去重鍵(同 persistNodes:id 優先、無則 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 = `canonical:gloss`(名+描述一起 embed,利於語意召回)。 + * - metadata_json.embed=true(base 讀此旗標)、source、node_id、canonical(回連原 node)。 + */ +export async function upsertGlossEntry( + client: KbdbClient, + node: GlossEntryInput, + owner_id?: string, +): Promise { + 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'; +} diff --git a/src/actions/node-persist.ts b/src/actions/node-persist.ts index ef32576..900c13e 100644 --- a/src/actions/node-persist.ts +++ b/src/actions/node-persist.ts @@ -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 { if (!nodes || nodes.length === 0) return; await ensurePluginTemplates(client); @@ -46,5 +48,12 @@ export async function persistNodes( }, owner_id, ); + + // 另落一筆 embeddable base entry(metadata.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); + } } } diff --git a/src/actions/triplet-ingest.ts b/src/actions/triplet-ingest.ts index bba23c8..bbfcf80 100644 --- a/src/actions/triplet-ingest.ts +++ b/src/actions/triplet-ingest.ts @@ -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 entry(source.uri 帶進 metadata.source)。 + await persistNodes(client, env.nodes ?? [], owner_id, env.source.uri); // 2) 後翻舊批 status=deprecated(指向本批 source_uri;append 在前 → 無空窗)。 for (const old of priorActive) { diff --git a/src/lib/kbdb-client.ts b/src/lib/kbdb-client.ts index 391d790..a0aa7a0 100644 --- a/src/lib/kbdb-client.ts +++ b/src/lib/kbdb-client.ts @@ -13,6 +13,9 @@ export type BaseEntry = { owner_id: string | null; parent_id?: string | null; page_name?: string | null; + // base entries 的通用 metadata(TEXT,JSON 字串)。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 key(entry-crud.ts:exact-match lookup)。 page_name?: string; + // metadata_json = base 既有欄位(JSON 字串)。標 { embed:true, source, ... } 供 base embed 模組讀標執行。 + metadata_json?: string; }; /** 基本盤 API client。所有方法 = 一個 HTTP 呼叫,零 SQL。 */ diff --git a/src/routes/entities.ts b/src/routes/entities.ts index 53234ad..6dcc80e 100644 --- a/src/routes/entities.ts +++ b/src/routes/entities.ts @@ -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 entry(metadata.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 }; diff --git a/tests/gloss-entry.test.ts b/tests/gloss-entry.test.ts new file mode 100644 index 0000000..52c45fb --- /dev/null +++ b/tests/gloss-entry.test.ts @@ -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) => + 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, + 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); + }); +}); diff --git a/tests/mock-client.ts b/tests/mock-client.ts index c26d290..9f94986 100644 --- a/tests/mock-client.ts +++ b/tests/mock-client.ts @@ -16,7 +16,16 @@ export class MockKbdbClient { async createEntry(input: any): Promise { 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 { + // 對齊 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), ); }