Files
kbdb-graph-plugin/tests/triplet-ingest.test.ts
T
Claude 4190fbcf90 feat(graph): owner_id 寫入鏈必經(根治無主資料)——配合 base owner-mandatory(D28)
- kbdb-client requireOwner 守衛:漏 owner 插件端即 throw,不再靜默送 owner:''
- owner 一路 thread:persistNodes/ingestEnvelope/entity-crud/triplet-crud 全必填
- 病灶修:POST /triplets/ingest 原本沒傳 owner→加 owner_id query+400
- 寫入 route 缺 owner→400;vitest 23→27
- 註:gloss-bridge 分支的 backfill/gloss-entry 另需套 owner 必填(附說明),不跨支疊改
2026-07-05 11:00:30 +00:00

213 lines
9.7 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 寫入端 — 走 mock KbdbClientAPI-as-Wall),零 SQL、不打網路。
// 覆蓋 T3.4 五案:正常 envelope / 同 hash no-op / 新 hash deprecate / 污染 envelope 422 / rollback。
// + owner 必經(D27/D28):漏 owner→插件端 fail;真 owner 一路 thread 進 triplet + node 寫入。
import { describe, it, expect } from 'vitest';
import { ingestEnvelope, IngestEnvelopeSchema, type IngestEnvelope } from '../src/actions/triplet-ingest';
import { queryTriplets } from '../src/actions/triplet-crud';
import { mockClient } from './mock-client';
const OWNER = 'leo';
function envelope(hash: string, triplets: IngestEnvelope['triplets']): IngestEnvelope {
return {
source: { uri: 'github:uncle6me-web/wiki@a.md', content_hash: hash },
extractor: { model: 'claude-sonnet-4-6', tier: 'deep' },
triplets,
};
}
describe('ingestEnvelope — 正常 envelope', () => {
it('append 全部 triplet 為 active,記 source_uri/content_hash', async () => {
const c = mockClient();
const res = await ingestEnvelope(c, envelope('h1', [
{ subject: 'A', predicate: 'rel', object: 'B' },
{ subject: 'B', predicate: 'rel', object: 'C' },
]), OWNER);
expect(res).toEqual({ skipped: false, ingested: 2, deprecated: 0 });
const { triplets } = await queryTriplets(c, {});
expect(triplets.length).toBe(2);
expect(triplets.every((t) => t.status === 'active')).toBe(true);
expect(triplets[0].source_uri).toBe('github:uncle6me-web/wiki@a.md');
expect(triplets[0].content_hash).toBe('h1');
});
});
describe('ingestEnvelope — owner 必經(D27/D28', () => {
it('漏 owner → 插件端 fail(不靜默送空,不寫出無主 triplet)', async () => {
const c = mockClient();
await expect(
// @ts-expect-error 蓄意漏 owner:型別上 owner 必填,執行期也應 throw
ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }])),
).rejects.toThrow(/owner/i);
// fail loud:一筆都不該寫進去(第一筆寫入即擋)。
const { triplets } = await queryTriplets(c, {});
expect(triplets.length).toBe(0);
});
it('真 owner 一路 thread 到 triplet + node 寫入(owner slot 非空、record owner_id=真 owner', async () => {
const c = mockClient();
const env: IngestEnvelope = {
source: { uri: 'github:uncle6me-web/wiki@own.md', content_hash: 'ho' },
extractor: { model: 'm', tier: 'deep' },
nodes: [{ name: 'Graph RAG', id: 'graph-rag.md', gloss: '關係遍歷檢索' }],
triplets: [{ subject: 'Graph RAG', predicate: 'r', object: 'X' }],
};
await ingestEnvelope(c, env, OWNER);
// triplet record 帶真 owner。
const triplets = await c.listRecordsByTemplate('triplet', OWNER);
expect(triplets.length).toBe(1);
// node → entity record 帶真 ownerowner slot + record owner_id 皆為 leo,非 None/空)。
const entities = await c.listRecordsByTemplate('entity', OWNER);
const gr = entities.find((e) => e.values.canonical === 'Graph RAG');
expect(gr).toBeDefined();
expect(gr!.values.owner).toBe(OWNER);
// 用錯 owner 查 → 濾掉(證明 record 確實掛在 leo 名下,非無主)。
expect((await c.listRecordsByTemplate('entity', 'someone-else')).length).toBe(0);
});
});
describe('ingestEnvelope — 同 hash no-op', () => {
it('同 uri+hash 再送 → skipped,不新增', async () => {
const c = mockClient();
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]), OWNER);
const res = await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]), OWNER);
expect(res.skipped).toBe(true);
const { triplets } = await queryTriplets(c, {});
expect(triplets.length).toBe(1); // 沒有重複 append
});
});
describe('ingestEnvelope — 新 hash deprecate-then-append', () => {
it('同 uri 新 hash → 舊批轉 deprecated、新批 active;查詢 active-only', async () => {
const c = mockClient();
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]), OWNER);
const res = await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]), OWNER);
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 1 });
// active-only 查詢只見新批。
const active = await queryTriplets(c, {});
expect(active.triplets.length).toBe(1);
expect(active.triplets[0].object).toBe('new');
// 舊批仍在(deprecated),可考古/rollback。
const all = await queryTriplets(c, { includeDeprecated: true });
expect(all.triplets.length).toBe(2);
const deprecated = all.triplets.find((t) => t.status === 'deprecated');
expect(deprecated?.object).toBe('old');
expect(deprecated?.superseded_by).toBe('h2');
});
});
describe('ingestEnvelope — 污染 envelope 422(契約 strict', () => {
it('triplet 帶 graph 領域欄位 bridge_score → schema 拒收', () => {
const polluted = {
source: { uri: 'u', content_hash: 'h' },
extractor: { model: 'm', tier: 'deep' },
triplets: [{ subject: 'A', predicate: 'r', object: 'B', bridge_score: 3 }],
};
const parsed = IngestEnvelopeSchema.safeParse(polluted);
expect(parsed.success).toBe(false);
});
it('envelope 頂層帶禁止欄位 id → 拒收', () => {
const polluted = {
id: 'should-not-send',
source: { uri: 'u', content_hash: 'h' },
extractor: { model: 'm', tier: 'deep' },
triplets: [{ subject: 'A', predicate: 'r', object: 'B' }],
};
expect(IngestEnvelopeSchema.safeParse(polluted).success).toBe(false);
});
});
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, OWNER);
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 0 });
const { triplets } = await queryTriplets(c, {});
expect(triplets.length).toBe(1);
// node 打標落地成 entity recordgloss/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, OWNER);
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();
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]), OWNER);
await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]), OWNER);
// 取出被 deprecate 的舊批 id,手動 rollback(翻回 active、清 superseded_by)。
const all = await queryTriplets(c, { includeDeprecated: true });
const old = all.triplets.find((t) => t.status === 'deprecated')!;
await c.updateRecord(old.id, { status: 'active', superseded_by: '' });
const active = await queryTriplets(c, {});
expect(active.triplets.map((t) => t.object).sort()).toEqual(['new', 'old']);
});
});