diff --git a/contracts/ingest-candidate.json b/contracts/ingest-candidate.json index 0eb9d22..7c693a5 100644 --- a/contracts/ingest-candidate.json +++ b/contracts/ingest-candidate.json @@ -60,7 +60,7 @@ }, "nodes": { "type": "array", - "description": "節點層附帶資訊(選填)。entity_type 與 gloss 是【節點】屬性,不是【邊】屬性 → 放這裡,不放 triplets。graph 用 gloss 去 embed(每節點一句,不是裸詞)、用 entity_type 去 typing。", + "description": "節點層附帶資訊。【向量化分工(leo 2026-06-26,ingest#1 升格成契約)】ingest 在此【打標】哪些 token 要向量化 + embed 什麼;base/KBDB embed 模組【讀標執行】實際 embedding;ingest 自己不算向量。兩類節點(實體詞條 / wikilink 卡)都進 nodes[],謂詞向量見 triplets[].predicate_vector。", "items": { "type": "object", "required": ["name"], @@ -69,11 +69,26 @@ "name": { "type": "string", "minLength": 1, - "description": "節點名(須對應某 triplet 的 subject 或 object 原字面)。" + "description": "節點名(須對應某 triplet 的 subject/object 原字面)。實體詞條=正規名;wikilink 卡=卡標題。" + }, + "id": { + "type": "string", + "description": "去重鍵。wikilink 卡用【檔名】→ 一卡一 node,被多條邊指到也只 embed 一次,不以出現次數重複。實體詞條用正規名。選填(無則以 name 去重)。" }, "gloss": { "type": "string", - "description": "一句話描述,供 embedding。例如 'Graph RAG — 用關係遍歷檢索、保住異見的 RAG 變體'。選填(建議 deep tier 產出)。" + "description": "一句話描述。base embed 對【名 + gloss 一起】embedding(實體同義詞字面差太遠,靠描述拉近)。選填(建議 deep tier 產)。" + }, + "aliases": { + "type": "array", + "items": { "type": "string" }, + "description": "同義詞(如『黃仁勳』/『Jensen Huang』)。base 歸一(collapse)成同一 node。選填。" + }, + "embed": { + "type": "boolean", + "default": true, + "description": "【向量化打標】此節點要不要進向量庫。true=base 讀標去 embed(名+gloss);false=base 看到就不理(如結構符號/散文不該進 nodes[],真進了標 false)。預設 true(實體詞條與 wikilink 卡都要)。", + "$comment": "ingest 打標,base 讀標執行。embed 動作歸 base embed 模組,ingest 不算向量。" }, "entity_type": { "type": "string", @@ -86,7 +101,7 @@ "triplets": { "type": "array", "minItems": 1, - "description": "邊(關係)。ingest 只產原始 (s,p,o) + confidence。", + "description": "邊(關係)。ingest 只產原始 (s,p,o) + confidence + 謂詞向量打標。端點(s/o)以字面 match nodes[].name。", "items": { "type": "object", "required": ["subject", "predicate", "object"], @@ -95,10 +110,16 @@ "subject": { "type": "string", "minLength": 1, "description": "主詞(實體名,須與 nodes[].name 對得上若有提供)" }, "predicate": { "type": "string", "minLength": 1, "description": "謂詞(關係)" }, "object": { "type": "string", "minLength": 1, "description": "受詞(目標實體或值)" }, + "predicate_embed": { + "type": "boolean", + "default": true, + "description": "【謂詞向量化打標】謂詞要不要 embed。base 讀標 → embed【謂詞裸詞,無描述】(謂詞同義詞字面本就近,如『參考』/『參照』,裸詞 embed 即自動聚類),存 edge 的 predicate_vector。為支援『關係過濾』查詢(查『參考』不漏『參照』)→ 預設 true。embed 動作歸 base,ingest 只打標。", + "$comment": "ingest 打標,base 讀標執行 embed。" + }, "confidence":{ "type": "number", "minimum": 0, "maximum": 1, "default": 1.0, "description": "萃取可信度。淺萃可附自評;graph 不據此過濾,只記錄。" } } } } }, - "$comment": "禁止欄位(graph 領域,ingest 絕不可送): id / clusters / bridge_score / created_at / updated_at / 以及 triplet 上的 subject_entity_type|object_entity_type(類型只走 nodes[])。送了即違反 ingest=純餵食器的邊界,graph 應拒收或忽略。" + "$comment": "禁止欄位(graph 領域,ingest 絕不可送): id(節點去重鍵的 id 例外,那是 ingest 提供的去重鍵非 record id) / clusters / bridge_score / created_at / updated_at / 以及 triplet 上的 subject_entity_type|object_entity_type(類型只走 nodes[])。【向量化分工】ingest 打標(embed/predicate_embed + 帶 gloss/aliases),base/KBDB embed 模組讀標執行 embedding,ingest 不算向量。結構符號(>>/←)與給人讀的散文(## 摘要)不進 envelope。" } diff --git a/src/actions/node-persist.ts b/src/actions/node-persist.ts new file mode 100644 index 0000000..ef32576 --- /dev/null +++ b/src/actions/node-persist.ts @@ -0,0 +1,50 @@ +// node 層打標落地 — 把 envelope nodes[] 的向量化打標(embed/gloss/aliases)存進 entity slot。 +// 向量化分工(ingest#1 升格,2026-06-26):ingest 打標、base/KBDB embed 模組讀標執行;graph 不算向量。 +// 鐵律:走 base API(API-as-Wall)、零 SQL。 + +import type { KbdbClient } from '../lib/kbdb-client'; +import { TPL_ENTITY, ensurePluginTemplates } from '../lib/templates'; + +export type IngestNode = { + name: string; + id?: string; + aliases?: string[]; + gloss?: string; + embed?: boolean; + entity_type?: string; +}; + +/** + * 把 node 層打標存進 entity record,供 base embed 模組讀標執行 embedding。 + * 去重:以 id(無則 name)為鍵,同鍵在這批內只存一筆——wikilink 卡被多條邊指到仍是一個 node。 + * graph 不做 embedding,只負責透傳/落地打標。 + */ +export async function persistNodes( + client: KbdbClient, + nodes: IngestNode[], + owner_id?: string, +): Promise { + if (!nodes || nodes.length === 0) return; + await ensurePluginTemplates(client); + + const seen = new Set(); + for (const n of nodes) { + const key = (n.id ?? n.name).toLowerCase().trim(); + if (seen.has(key)) continue; // 同卡多邊指到 → 只存一次 + seen.add(key); + await client.createRecord( + TPL_ENTITY, + { + canonical: n.name, + node_id: n.id ?? '', + aliases_json: JSON.stringify(n.aliases ?? []), + entity_type: n.entity_type ?? '', + gloss: n.gloss ?? '', + // contract 預設 true;只在明確 false 時存標(base 看 'false' 跳過 embed)。 + embed: n.embed === false ? 'false' : 'true', + owner: owner_id ?? '', + }, + owner_id, + ); + } +} diff --git a/src/actions/triplet-crud.ts b/src/actions/triplet-crud.ts index 61f0fb9..da541bc 100644 --- a/src/actions/triplet-crud.ts +++ b/src/actions/triplet-crud.ts @@ -21,6 +21,7 @@ export type CreateTripletData = { source_uri?: string; content_hash?: string; source_anchor?: string; + predicate_embed?: boolean; // 謂詞向量化打標(ingest 打標、base 讀標執行);graph 不算向量 }; /** 建立三元組 → POST /records(template=triplet)。 */ @@ -48,6 +49,7 @@ export async function createTriplet( if (data.source_uri) values.source_uri = data.source_uri; if (data.content_hash) values.content_hash = data.content_hash; if (data.source_anchor) values.source_anchor = data.source_anchor; + if (data.predicate_embed === false) values.predicate_embed = 'false'; // 謂詞向量化打標透傳,base 讀標執行 const id = await client.createRecord(TPL_TRIPLET, values, data.owner_id); return { id, subject: data.subject, predicate: data.predicate, object: data.object }; diff --git a/src/actions/triplet-ingest.ts b/src/actions/triplet-ingest.ts index 3ce6505..bba23c8 100644 --- a/src/actions/triplet-ingest.ts +++ b/src/actions/triplet-ingest.ts @@ -6,11 +6,17 @@ import { z } from '@hono/zod-openapi'; import type { KbdbClient } from '../lib/kbdb-client'; import { TPL_TRIPLET, ensurePluginTemplates, recordToTriplet } from '../lib/templates'; import { createTriplet } from './triplet-crud'; +import { persistNodes } from './node-persist'; // Zod 鏡射契約:strict() = additionalProperties:false → 禁送欄位 422(route 把 ZodError 轉 422)。 +// 向量化打標欄位(ingest#1 升格,2026-06-26):ingest 打標、base/KBDB embed 模組讀標執行;graph 自己不算向量。 +// strict() 仍保留 → 真正的 graph 領域禁送欄位(bridge_score / clusters / 邊上 entity_type)照樣 422。 const NodeSchema = z.object({ name: z.string().min(1), + id: z.string().optional(), // 去重鍵(wikilink 卡用檔名 → 一卡一 node,多邊指到不重建) + aliases: z.array(z.string()).optional(), // 同義詞,base collapse 成同一 node gloss: z.string().optional(), + embed: z.boolean().optional(), // 向量化打標,base 讀標執行(預設 true) entity_type: z.enum(['person', 'event', 'product', 'market', 'org']).optional(), }).strict(); @@ -18,6 +24,7 @@ const EdgeSchema = z.object({ subject: z.string().min(1), predicate: z.string().min(1), object: z.string().min(1), + predicate_embed: z.boolean().optional(), // 謂詞向量化打標,base 讀標執行(預設 true) confidence: z.number().min(0).max(1).optional(), }).strict(); @@ -59,13 +66,14 @@ export async function ingestEnvelope( return { skipped: true, ingested: 0, deprecated: 0 }; } - // 1) 先 append 新批 active。 + // 1) 先 append 新批 active(透傳 predicate_embed 打標,供 base embed 模組讀標執行)。 for (const e of env.triplets) { await createTriplet(client, { subject: e.subject, predicate: e.predicate, object: e.object, confidence: e.confidence, + predicate_embed: e.predicate_embed, source_block_id: env.source.block_id, source_uri: env.source.uri, content_hash: env.source.content_hash, @@ -74,6 +82,10 @@ export async function ingestEnvelope( }); } + // 1b) 落地 node 層打標(embed / gloss / aliases),供 base embed 模組讀標執行 embedding。 + // graph 自己不算向量(鐵律一致)。id 作去重鍵:同一卡(同 id/檔名)只存一筆 entity,不以邊數重複。 + await persistNodes(client, env.nodes ?? [], owner_id); + // 2) 後翻舊批 status=deprecated(指向本批 source_uri;append 在前 → 無空窗)。 for (const old of priorActive) { await client.updateRecord(old.id, { status: 'deprecated', superseded_by: env.source.content_hash }); diff --git a/src/lib/templates.ts b/src/lib/templates.ts index 275bb7d..30ec39e 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -18,9 +18,13 @@ export const TRIPLET_SLOTS = [ // source_uri+content_hash 承載 ingest idempotency(按 source_uri 分組 deprecate)。 // source_anchor 供 get_source 精準回跳原文(T3.7)。 'status', 'superseded_by', 'source_uri', 'content_hash', 'source_anchor', + // 謂詞向量化打標(ingest#1 升格,2026-06-26):ingest 打標、base embed 模組讀標執行;graph 不算向量。 + 'predicate_embed', ]; // gloss(T3.2b):一句話描述,供「詞+gloss」語義 normalize 的 embedding 對象。 -export const ENTITY_SLOTS = ['canonical', 'aliases_json', 'entity_type', 'owner', 'gloss']; +// embed(ingest#1 升格,2026-06-26):向量化打標,base embed 模組讀標執行;graph 不算向量。 +// node_id(去重鍵):wikilink 卡用檔名,一卡一 node、多邊指到只 embed 一次(base 讀此鍵歸一)。 +export const ENTITY_SLOTS = ['canonical', 'aliases_json', 'entity_type', 'owner', 'gloss', 'embed', 'node_id']; export const ENTITY_PENDING_SLOTS = [ 'raw_name', 'candidate_entity_id', 'candidate_canonical', 'similarity', ]; diff --git a/tests/triplet-ingest.test.ts b/tests/triplet-ingest.test.ts index 33e0b79..399547a 100644 --- a/tests/triplet-ingest.test.ts +++ b/tests/triplet-ingest.test.ts @@ -85,6 +85,78 @@ describe('ingestEnvelope — 污染 envelope 422(契約 strict)', () => { }); }); +describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1)', () => { + it('帶 nodes[].embed/id/aliases + triplets[].predicate_embed → 通過(非 422)', async () => { + const c = mockClient(); + const env: IngestEnvelope = { + source: { uri: 'github:uncle6me-web/wiki@v.md', content_hash: 'hv' }, + extractor: { model: 'claude-sonnet-4-6', tier: 'deep' }, + nodes: [ + { name: 'Graph RAG', id: 'graph-rag.md', aliases: ['圖譜 RAG'], gloss: '關係遍歷檢索', embed: true }, + { name: '黃仁勳', id: '黃仁勳', aliases: ['Jensen Huang'], embed: false }, + ], + triplets: [ + { subject: 'Graph RAG', predicate: '參考', object: '黃仁勳', predicate_embed: true }, + ], + }; + // schema 層先驗:合法新欄位不被 strict 擋。 + expect(IngestEnvelopeSchema.safeParse(env).success).toBe(true); + + // 落地:triplet 寫入、node 打標存進 entity slot。 + const res = await ingestEnvelope(c, env); + expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 0 }); + + const { triplets } = await queryTriplets(c, {}); + expect(triplets.length).toBe(1); + + // node 打標落地成 entity record(gloss/aliases/embed 標示透傳供 base 讀)。 + const entities = await c.listRecordsByTemplate('entity'); + const gr = entities.find((e) => e.values.canonical === 'Graph RAG'); + expect(gr?.values.node_id).toBe('graph-rag.md'); + expect(gr?.values.embed).toBe('true'); + expect(JSON.parse(gr!.values.aliases_json!)).toEqual(['圖譜 RAG']); + expect(gr?.values.gloss).toBe('關係遍歷檢索'); + const jensen = entities.find((e) => e.values.canonical === '黃仁勳'); + expect(jensen?.values.embed).toBe('false'); // 明確 false 透傳 + }); + + it('同 id 的 node 被多次帶入 → 去重,只存一筆 entity(一卡一 node)', async () => { + const c = mockClient(); + const env: IngestEnvelope = { + source: { uri: 'github:uncle6me-web/wiki@dup.md', content_hash: 'hd' }, + extractor: { model: 'm', tier: 'deep' }, + nodes: [ + { name: 'Graph RAG', id: 'graph-rag.md' }, + { name: 'Graph RAG(別名)', id: 'graph-rag.md' }, // 同 id → 同卡,不重建 + ], + triplets: [{ subject: 'Graph RAG', predicate: 'r', object: 'X' }], + }; + await ingestEnvelope(c, env); + const entities = await c.listRecordsByTemplate('entity'); + expect(entities.filter((e) => e.values.node_id === 'graph-rag.md').length).toBe(1); + }); + + it('帶真正 graph 領域禁送欄位(bridge_score)→ 仍 422', () => { + const polluted = { + source: { uri: 'u', content_hash: 'h' }, + extractor: { model: 'm', tier: 'deep' }, + nodes: [{ name: 'A', embed: true }], + triplets: [{ subject: 'A', predicate: 'r', object: 'B', predicate_embed: true, bridge_score: 3 }], + }; + expect(IngestEnvelopeSchema.safeParse(polluted).success).toBe(false); + }); + + it('node 帶 graph 領域禁送欄位(clusters)→ 仍 422', () => { + const polluted = { + source: { uri: 'u', content_hash: 'h' }, + extractor: { model: 'm', tier: 'deep' }, + nodes: [{ name: 'A', embed: true, clusters: ['c1'] }], + triplets: [{ subject: 'A', predicate: 'r', object: 'B' }], + }; + expect(IngestEnvelopeSchema.safeParse(polluted).success).toBe(false); + }); +}); + describe('ingestEnvelope — rollback(翻回 status)', () => { it('把 deprecated 翻回 active 後,active 查詢重新見到它', async () => { const c = mockClient();