Files
kbdb-graph-plugin/src/actions/triplet-ingest.ts
T
uncle6 13db97bb54 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>
2026-06-26 20:28:13 +08:00

96 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ingest 寫入端 — 收 ingest-candidate envelope,做 idempotency + deprecate-then-append。
// 契約:contracts/ingest-candidate.json。鐵律:走 base API、零 SQL。
// 取代策略:先 append 新批 active,後翻舊批 status=deprecated(中途失敗不留「全無 active」空窗)。
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();
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();
export const IngestEnvelopeSchema = z.object({
source: z.object({
uri: z.string().min(1),
content_hash: z.string().min(1),
anchor: z.string().optional(),
commit: z.string().optional(),
block_id: z.string().optional(),
}).strict(),
extractor: z.object({
model: z.string().min(1),
tier: z.enum(['shallow', 'deep']),
extracted_at: z.number().int().optional(),
}).strict(),
nodes: z.array(NodeSchema).optional(),
triplets: z.array(EdgeSchema).min(1),
}).strict();
export type IngestEnvelope = z.infer<typeof IngestEnvelopeSchema>;
export type IngestResult = { skipped: boolean; ingested: number; deprecated: number };
/** 收 envelope → idempotency → 先 append 後 deprecate。回 {skipped,ingested,deprecated}。 */
export async function ingestEnvelope(
client: KbdbClient,
env: IngestEnvelope,
owner_id?: string,
): Promise<IngestResult> {
await ensurePluginTemplates(client);
// 同 source_uri 的現存 active tripletidempotency 分組 + 待 deprecate 對象)。
const all = (await client.listRecordsByTemplate(TPL_TRIPLET, owner_id)).map(recordToTriplet);
const priorActive = all.filter((t) => t.source_uri === env.source.uri && t.status === 'active');
// 同 hash → no-openvelope 已落地過)。
if (priorActive.some((t) => t.content_hash === env.source.content_hash)) {
return { skipped: true, ingested: 0, deprecated: 0 };
}
// 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,
source_anchor: env.source.anchor,
owner_id,
});
}
// 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 });
}
return { skipped: false, ingested: env.triplets.length, deprecated: priorActive.length };
}