fix(ingest): receiver Zod 追上 contract — 補向量化打標欄位 (issue #1 補對齊) (#3)

契約漂移修補:T3 的 strict Zod 鏡射舊 contract,ingest 照新 contract(ingest#1
升格)送向量化打標欄位會被 .strict() 擋成 422。方向 A:顯式加合法新欄位、保留
strict。

- 同步 contracts/ingest-candidate.json 副本到頂層單一真相源(mira-dissolve)。
- NodeSchema 加 id?/aliases?/embed?;EdgeSchema 加 predicate_embed?。strict() 保留
  → bridge_score/clusters 等 graph 領域禁送欄位仍 422。
- 落地:predicate_embed 透傳進 triplet slot;node 打標(embed/gloss/aliases)存進
  entity slot,供 base/KBDB embed 模組讀標執行(graph 不算向量,鐵律一致)。
- id 作 node 去重鍵:同卡多邊指到只存一筆 entity。
- persistNodes 拆成獨立 action(triplet-ingest.ts 回到 95 行,守樂高 100 行限制)。
- 測試 +4:帶向量化欄位通過、bridge_score/clusters 仍 422、同 id 去重。
  vitest 23 passed。零 SQL / 無 D1·Vectorize·AI 綁定 / dry-run 乾淨。

Co-authored-by: richblack <leo21c@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6
2026-06-26 20:28:13 +08:00
committed by GitHub
parent 7a29dee357
commit 13db97bb54
6 changed files with 168 additions and 7 deletions
+50
View File
@@ -0,0 +1,50 @@
// node 層打標落地 — 把 envelope nodes[] 的向量化打標(embed/gloss/aliases)存進 entity slot。
// 向量化分工(ingest#1 升格,2026-06-26):ingest 打標、base/KBDB embed 模組讀標執行;graph 不算向量。
// 鐵律:走 base APIAPI-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<void> {
if (!nodes || nodes.length === 0) return;
await ensurePluginTemplates(client);
const seen = new Set<string>();
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,
);
}
}
+2
View File
@@ -21,6 +21,7 @@ export type CreateTripletData = {
source_uri?: string;
content_hash?: string;
source_anchor?: string;
predicate_embed?: boolean; // 謂詞向量化打標(ingest 打標、base 讀標執行);graph 不算向量
};
/** 建立三元組 → POST /recordstemplate=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 };
+13 -1
View File
@@ -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 → 禁送欄位 422route 把 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_uriappend 在前 → 無空窗)。
for (const old of priorActive) {
await client.updateRecord(old.id, { status: 'deprecated', superseded_by: env.source.content_hash });