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:
Claude
2026-07-05 11:20:47 +00:00
8 changed files with 272 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, // 必經:存量 entity record 本身 owner=Noneowner 由 caller 明確指定(配合 owner-mandatory D28
): 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, // 必經:owner 從 persistNodes / backfill caller 帶真 owner,不得為 None(配合 owner-mandatory D28
): 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, // 必經:owner 一路從 ingest envelope / route 帶到底,不得掉成空字串
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, // createRecord 內 requireOwner 守衛:缺→throw
);
// 另落一筆 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;
};
@@ -29,7 +32,10 @@ export type CreateEntryInput = {
// owner_id 必經(D27/D28):型別上必填、且執行期缺→throw。不再靜默送空 → 不寫出無主資料。
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;
};
/**
+12
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 }>();
@@ -70,4 +71,15 @@ 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 };
// owner 必經:存量 entity record 本身 owner=Noneowner 由 caller 明確指定(不從 record 帶)。缺→400 不寫無主資料。
const owner_id = c.req.query('owner_id') || body?.owner_id;
if (!owner_id) return c.json({ success: false, error: 'owner_id required' }, 400);
const result = await backfillGlossEntries(makeKbdbClient(c.env), owner_id);
return c.json({ success: true, ...result });
});
export { entityRoutes };