// 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 → 禁送欄位 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(); 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; 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 { await ensurePluginTemplates(client); // 同 source_uri 的現存 active triplet(idempotency 分組 + 待 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-op(envelope 已落地過)。 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_uri;append 在前 → 無空窗)。 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 }; }