13db97bb54
契約漂移修補: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>
96 lines
4.3 KiB
TypeScript
96 lines
4.3 KiB
TypeScript
// 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<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 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 };
|
||
}
|