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 必填(附說明),不跨支疊改
This commit is contained in:
@@ -13,12 +13,12 @@ const norm = (s: string): string => s.toLowerCase().trim();
|
|||||||
// ─── Entity ──────────────────────────────────────────────────────────────────
|
// ─── Entity ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** 建立 Entity(canonical name)。底層 = 一筆 entity template record。 */
|
/** 建立 Entity(canonical name)。底層 = 一筆 entity template record。 */
|
||||||
export async function createEntity(client: KbdbClient, canonical: string, owner?: string): Promise<Entity> {
|
export async function createEntity(client: KbdbClient, canonical: string, owner: string): Promise<Entity> {
|
||||||
await ensurePluginTemplates(client);
|
await ensurePluginTemplates(client);
|
||||||
const id = await client.createRecord(
|
const id = await client.createRecord(
|
||||||
TPL_ENTITY,
|
TPL_ENTITY,
|
||||||
{ canonical, aliases_json: '[]', entity_type: '', owner: owner ?? '' },
|
{ canonical, aliases_json: '[]', entity_type: '', owner }, // 真 owner 落 slot(不再 ?? '')
|
||||||
owner,
|
owner, // 必經:createRecord 內 requireOwner 守衛
|
||||||
);
|
);
|
||||||
return { id, canonical, aliases: [] };
|
return { id, canonical, aliases: [] };
|
||||||
}
|
}
|
||||||
@@ -45,21 +45,23 @@ export async function listEntities(client: KbdbClient, limit = 100, owner?: stri
|
|||||||
* 新增 alias。base 無 PUT /records/:id → 改「重建一筆新 entity record」覆寫(含舊 canonical + 既有 aliases + 新 alias)。
|
* 新增 alias。base 無 PUT /records/:id → 改「重建一筆新 entity record」覆寫(含舊 canonical + 既有 aliases + 新 alias)。
|
||||||
* [→arcrun] base 缺 PUT /records/:id:補上後改為原地 patch aliases_json,省一次重建。
|
* [→arcrun] base 缺 PUT /records/:id:補上後改為原地 patch aliases_json,省一次重建。
|
||||||
*/
|
*/
|
||||||
export async function addAlias(client: KbdbClient, entityId: string, alias: string, owner?: string): Promise<void> {
|
export async function addAlias(client: KbdbClient, entityId: string, alias: string, owner: string): Promise<void> {
|
||||||
const rec = await client.getRecord(entityId);
|
const rec = await client.getRecord(entityId);
|
||||||
if (!rec) throw new Error(`Entity ${entityId} not found`);
|
if (!rec) throw new Error(`Entity ${entityId} not found`);
|
||||||
const ent = recordToEntity(rec);
|
const ent = recordToEntity(rec);
|
||||||
if (ent.aliases.includes(alias)) return;
|
if (ent.aliases.includes(alias)) return;
|
||||||
const aliases = [...ent.aliases, alias];
|
const aliases = [...ent.aliases, alias];
|
||||||
await ensurePluginTemplates(client);
|
await ensurePluginTemplates(client);
|
||||||
|
// 重建時沿用原 record 既有 owner;原 record 無主(舊資料 owner=None)才退回 caller 指定的 owner。
|
||||||
|
const effectiveOwner = (rec.values.owner || '').trim() || owner;
|
||||||
await client.createRecord(
|
await client.createRecord(
|
||||||
TPL_ENTITY,
|
TPL_ENTITY,
|
||||||
{
|
{
|
||||||
canonical: ent.canonical,
|
canonical: ent.canonical,
|
||||||
aliases_json: JSON.stringify(aliases),
|
aliases_json: JSON.stringify(aliases),
|
||||||
entity_type: rec.values.entity_type ?? '',
|
entity_type: rec.values.entity_type ?? '',
|
||||||
owner: rec.values.owner ?? owner ?? '',
|
owner: effectiveOwner,
|
||||||
},
|
},
|
||||||
owner,
|
effectiveOwner, // 必經:createRecord 內 requireOwner 守衛
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { KbdbClient } from '../lib/kbdb-client';
|
|||||||
export async function normalizeEntity(
|
export async function normalizeEntity(
|
||||||
client: KbdbClient,
|
client: KbdbClient,
|
||||||
rawName: string,
|
rawName: string,
|
||||||
owner?: string,
|
owner: string, // 必經:未命中會建新 entity,必帶 owner
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const exact = await findEntityByName(client, rawName, owner);
|
const exact = await findEntityByName(client, rawName, owner);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function createPendingAlias(
|
|||||||
candidateEntityId: string,
|
candidateEntityId: string,
|
||||||
candidateCanonical: string,
|
candidateCanonical: string,
|
||||||
similarity: number,
|
similarity: number,
|
||||||
owner?: string,
|
owner: string, // 必經:pending record 也不可無主
|
||||||
): Promise<PendingAlias> {
|
): Promise<PendingAlias> {
|
||||||
await ensurePluginTemplates(client);
|
await ensurePluginTemplates(client);
|
||||||
const id = await client.createRecord(
|
const id = await client.createRecord(
|
||||||
@@ -27,7 +27,7 @@ export async function createPendingAlias(
|
|||||||
candidate_canonical: candidateCanonical,
|
candidate_canonical: candidateCanonical,
|
||||||
similarity: String(similarity),
|
similarity: String(similarity),
|
||||||
},
|
},
|
||||||
owner,
|
owner, // 必經:createRecord 內 requireOwner 守衛
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -56,14 +56,14 @@ export async function getPendingAliases(client: KbdbClient, limit = 100, owner?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 確認 → addAlias 到候選 entity。pending soft 保留([→arcrun] base 缺 DELETE record)。 */
|
/** 確認 → addAlias 到候選 entity。pending soft 保留([→arcrun] base 缺 DELETE record)。 */
|
||||||
export async function confirmPendingAlias(client: KbdbClient, pendingId: string, owner?: string): Promise<void> {
|
export async function confirmPendingAlias(client: KbdbClient, pendingId: string, owner: string): Promise<void> {
|
||||||
const rec = await client.getRecord(pendingId);
|
const rec = await client.getRecord(pendingId);
|
||||||
if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`);
|
if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`);
|
||||||
await addAlias(client, rec.values.candidate_entity_id, rec.values.raw_name, owner);
|
await addAlias(client, rec.values.candidate_entity_id, rec.values.raw_name, owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拒絕 → 以 raw_name 建新 entity。pending soft 保留([→arcrun] base 缺 DELETE record)。 */
|
/** 拒絕 → 以 raw_name 建新 entity。pending soft 保留([→arcrun] base 缺 DELETE record)。 */
|
||||||
export async function rejectPendingAlias(client: KbdbClient, pendingId: string, owner?: string): Promise<Entity> {
|
export async function rejectPendingAlias(client: KbdbClient, pendingId: string, owner: string): Promise<Entity> {
|
||||||
const rec = await client.getRecord(pendingId);
|
const rec = await client.getRecord(pendingId);
|
||||||
if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`);
|
if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`);
|
||||||
return createEntity(client, rec.values.raw_name, owner);
|
return createEntity(client, rec.values.raw_name, owner);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type IngestNode = {
|
|||||||
export async function persistNodes(
|
export async function persistNodes(
|
||||||
client: KbdbClient,
|
client: KbdbClient,
|
||||||
nodes: IngestNode[],
|
nodes: IngestNode[],
|
||||||
owner_id?: string,
|
owner_id: string, // 必經:owner 一路從 ingest envelope / route 帶到底,不得掉成空字串
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!nodes || nodes.length === 0) return;
|
if (!nodes || nodes.length === 0) return;
|
||||||
await ensurePluginTemplates(client);
|
await ensurePluginTemplates(client);
|
||||||
@@ -42,9 +42,9 @@ export async function persistNodes(
|
|||||||
gloss: n.gloss ?? '',
|
gloss: n.gloss ?? '',
|
||||||
// contract 預設 true;只在明確 false 時存標(base 看 'false' 跳過 embed)。
|
// contract 預設 true;只在明確 false 時存標(base 看 'false' 跳過 embed)。
|
||||||
embed: n.embed === false ? 'false' : 'true',
|
embed: n.embed === false ? 'false' : 'true',
|
||||||
owner: owner_id ?? '',
|
owner: owner_id, // 真 owner 落 slot(不再 ?? '')
|
||||||
},
|
},
|
||||||
owner_id,
|
owner_id, // createRecord 內 requireOwner 守衛:缺→throw
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type CreateTripletData = {
|
|||||||
object: string;
|
object: string;
|
||||||
source_block_id?: string;
|
source_block_id?: string;
|
||||||
confidence?: number;
|
confidence?: number;
|
||||||
owner_id?: string;
|
owner_id: string; // 必經:三元組寫入必帶 owner(createRecord 內 requireOwner 守衛)
|
||||||
clusters?: string[];
|
clusters?: string[];
|
||||||
bridge_score?: number;
|
bridge_score?: number;
|
||||||
subject_entity_type?: string;
|
subject_entity_type?: string;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export async function extractTripletsViaLLM(ai: Ai, chunks: string[]): Promise<L
|
|||||||
export async function writeTripletToDb(
|
export async function writeTripletToDb(
|
||||||
client: KbdbClient,
|
client: KbdbClient,
|
||||||
t: { subject: string; predicate: string; object: string; confidence?: number },
|
t: { subject: string; predicate: string; object: string; confidence?: number },
|
||||||
owner: string | null,
|
owner: string, // 必經:萃取寫入必帶 owner(不再收 null → 不會 owner_id: undefined)
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// 查重:以 S-P-O 三欄精確比對(queryTriplets 取 template record 後在插件層 filter)
|
// 查重:以 S-P-O 三欄精確比對(queryTriplets 取 template record 後在插件層 filter)
|
||||||
const { count } = await queryTriplets(client, {
|
const { count } = await queryTriplets(client, {
|
||||||
@@ -71,7 +71,7 @@ export async function writeTripletToDb(
|
|||||||
predicate: t.predicate,
|
predicate: t.predicate,
|
||||||
object: t.object,
|
object: t.object,
|
||||||
confidence: t.confidence ?? 0.8,
|
confidence: t.confidence ?? 0.8,
|
||||||
owner_id: owner ?? undefined,
|
owner_id: owner,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export type IngestResult = { skipped: boolean; ingested: number; deprecated: num
|
|||||||
export async function ingestEnvelope(
|
export async function ingestEnvelope(
|
||||||
client: KbdbClient,
|
client: KbdbClient,
|
||||||
env: IngestEnvelope,
|
env: IngestEnvelope,
|
||||||
owner_id?: string,
|
owner_id: string, // 必經:owner 從 route 帶入,一路 thread 進 triplet + node 寫入,不得掉成空
|
||||||
): Promise<IngestResult> {
|
): Promise<IngestResult> {
|
||||||
await ensurePluginTemplates(client);
|
await ensurePluginTemplates(client);
|
||||||
|
|
||||||
|
|||||||
+24
-4
@@ -26,11 +26,29 @@ export type BaseRecord = {
|
|||||||
export type CreateEntryInput = {
|
export type CreateEntryInput = {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
entry_type: string;
|
entry_type: string;
|
||||||
owner_id?: string;
|
// owner_id 必經(D27/D28):型別上必填、且執行期缺→throw。不再靜默送空 → 不寫出無主資料。
|
||||||
|
owner_id: string;
|
||||||
parent_id?: string;
|
parent_id?: string;
|
||||||
page_name?: string;
|
page_name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* owner_id 必經守衛(D27/D28,2026-07-05)。
|
||||||
|
* 病根:漏 owner → 靜默送 owner:'' → base 寫成 owner=None → mira owner 過濾濾掉 → 使用者查不到
|
||||||
|
* (實測回填 16 筆 gloss entry owner=None)。base 即將把 owner 設必填(缺→400);插件端先擋,fail loud。
|
||||||
|
* 任何寫入前呼叫此守衛:漏 owner 在插件端就爆、不會送到 base,錯誤訊息指名該從最外層 route/envelope 帶入。
|
||||||
|
*/
|
||||||
|
export function requireOwner(owner_id: string | null | undefined, op: string): string {
|
||||||
|
const v = (owner_id ?? '').trim();
|
||||||
|
if (!v) {
|
||||||
|
throw new Error(
|
||||||
|
`[kbdb-graph] ${op} 缺 owner_id:owner 為必經欄位,不可寫出無主資料。` +
|
||||||
|
`請從最外層(route 參數 / ingest envelope)把真 owner thread 到底。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
/** 基本盤 API client。所有方法 = 一個 HTTP 呼叫,零 SQL。 */
|
/** 基本盤 API client。所有方法 = 一個 HTTP 呼叫,零 SQL。 */
|
||||||
export class KbdbClient {
|
export class KbdbClient {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -70,7 +88,8 @@ export class KbdbClient {
|
|||||||
// --- entries ---
|
// --- entries ---
|
||||||
|
|
||||||
async createEntry(input: CreateEntryInput): Promise<BaseEntry> {
|
async createEntry(input: CreateEntryInput): Promise<BaseEntry> {
|
||||||
const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', input);
|
const owner_id = requireOwner(input.owner_id, 'createEntry');
|
||||||
|
const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', { ...input, owner_id });
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +173,12 @@ export class KbdbClient {
|
|||||||
|
|
||||||
// --- records(= template 實例,填 slot) ---
|
// --- records(= template 實例,填 slot) ---
|
||||||
|
|
||||||
async createRecord(template: string, values: Record<string, string>, owner_id?: string): Promise<string> {
|
async createRecord(template: string, values: Record<string, string>, owner_id: string): Promise<string> {
|
||||||
|
const owner = requireOwner(owner_id, `createRecord(template=${template})`);
|
||||||
const { record } = await this.req<{ record: { record_id: string } }>('POST', '/records', {
|
const { record } = await this.req<{ record: { record_id: string } }>('POST', '/records', {
|
||||||
template,
|
template,
|
||||||
values,
|
values,
|
||||||
owner_id,
|
owner_id: owner,
|
||||||
});
|
});
|
||||||
return record.record_id;
|
return record.record_id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,13 +54,19 @@ entityRoutes.openapi(listPendingRoute, async (c) => {
|
|||||||
|
|
||||||
entityRoutes.post('/pending/:id/confirm', async (c) => {
|
entityRoutes.post('/pending/:id/confirm', async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
await confirmPendingAlias(makeKbdbClient(c.env), id);
|
// owner 必經:confirm 會 addAlias(重建 entity record),缺 owner→400 不寫無主資料。
|
||||||
|
const owner = c.req.query('owner_id')?.trim();
|
||||||
|
if (!owner) return c.json({ error: 'owner_id query parameter required(資料不可無主)' }, 400);
|
||||||
|
await confirmPendingAlias(makeKbdbClient(c.env), id, owner);
|
||||||
return c.json({ success: true, action: 'confirmed', id });
|
return c.json({ success: true, action: 'confirmed', id });
|
||||||
});
|
});
|
||||||
|
|
||||||
entityRoutes.post('/pending/:id/reject', async (c) => {
|
entityRoutes.post('/pending/:id/reject', async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const newEntity = await rejectPendingAlias(makeKbdbClient(c.env), id);
|
// owner 必經:reject 會 createEntity,缺 owner→400 不寫無主資料。
|
||||||
|
const owner = c.req.query('owner_id')?.trim();
|
||||||
|
if (!owner) return c.json({ error: 'owner_id query parameter required(資料不可無主)' }, 400);
|
||||||
|
const newEntity = await rejectPendingAlias(makeKbdbClient(c.env), id, owner);
|
||||||
return c.json({ success: true, action: 'rejected', newEntity });
|
return c.json({ success: true, action: 'rejected', newEntity });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const createRouteDefinition = createRoute({
|
|||||||
subject: z.string().min(1),
|
subject: z.string().min(1),
|
||||||
predicate: z.string().min(1),
|
predicate: z.string().min(1),
|
||||||
object: z.string().min(1),
|
object: z.string().min(1),
|
||||||
owner_id: z.string().optional(),
|
owner_id: z.string().min(1), // 必填:缺→400(資料不可無主)
|
||||||
source_block_id: z.string().optional(),
|
source_block_id: z.string().optional(),
|
||||||
confidence: z.number().optional(),
|
confidence: z.number().optional(),
|
||||||
clusters: z.array(z.string()).optional(),
|
clusters: z.array(z.string()).optional(),
|
||||||
@@ -103,10 +103,13 @@ const ingestRoute = createRoute({
|
|||||||
method: 'post',
|
method: 'post',
|
||||||
path: '/ingest',
|
path: '/ingest',
|
||||||
request: {
|
request: {
|
||||||
|
// owner_id 走 route 參數(envelope 是 .strict() 凍結契約,不塞 owner 進去)。缺→400。
|
||||||
|
query: z.object({ owner_id: z.string().optional().describe('資料所有者(必填,缺→400)') }),
|
||||||
body: { content: { 'application/json': { schema: IngestEnvelopeSchema } } },
|
body: { content: { 'application/json': { schema: IngestEnvelopeSchema } } },
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'Envelope ingested (or skipped if same content_hash)' },
|
200: { description: 'Envelope ingested (or skipped if same content_hash)' },
|
||||||
|
400: { description: 'Missing owner_id (資料不可無主)' },
|
||||||
422: { description: 'Invalid envelope (forbidden field or shape mismatch)' },
|
422: { description: 'Invalid envelope (forbidden field or shape mismatch)' },
|
||||||
},
|
},
|
||||||
tags: ['Triplets'],
|
tags: ['Triplets'],
|
||||||
@@ -115,8 +118,11 @@ const ingestRoute = createRoute({
|
|||||||
tripletRoutes.openapi(
|
tripletRoutes.openapi(
|
||||||
ingestRoute,
|
ingestRoute,
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
// owner 必經:缺→400(不寫出無主 triplet/node;避免再現 owner=None 被 mira 過濾)。
|
||||||
|
const owner_id = c.req.query('owner_id')?.trim();
|
||||||
|
if (!owner_id) return c.json({ error: 'owner_id query parameter required(資料不可無主)' }, 400);
|
||||||
const env = c.req.valid('json');
|
const env = c.req.valid('json');
|
||||||
const result = await ingestEnvelope(makeKbdbClient(c.env), env);
|
const result = await ingestEnvelope(makeKbdbClient(c.env), env, owner_id);
|
||||||
return c.json(result, 200);
|
return c.json(result, 200);
|
||||||
},
|
},
|
||||||
// strict() 驗證失敗(如送禁止欄位 bridge_score)→ 422,不是預設 400。
|
// strict() 驗證失敗(如送禁止欄位 bridge_score)→ 422,不是預設 400。
|
||||||
|
|||||||
+20
-8
@@ -5,17 +5,29 @@ import { normalizeEntity } from '../src/actions/entity-normalize';
|
|||||||
import { mockClient } from './mock-client';
|
import { mockClient } from './mock-client';
|
||||||
|
|
||||||
describe('entity-crud', () => {
|
describe('entity-crud', () => {
|
||||||
it('建立後可 exact 查回(大小寫不敏感)', async () => {
|
it('建立後可 exact 查回(大小寫不敏感),owner slot 帶真 owner', async () => {
|
||||||
const c = mockClient();
|
const c = mockClient();
|
||||||
await createEntity(c, 'InkStone');
|
const ent = await createEntity(c, 'InkStone', 'leo');
|
||||||
const found = await findEntityByName(c, 'inkstone');
|
const found = await findEntityByName(c, 'inkstone', 'leo');
|
||||||
expect(found?.canonical).toBe('InkStone');
|
expect(found?.canonical).toBe('InkStone');
|
||||||
|
// owner 落底:record 掛在 leo 名下(非無主),用錯 owner 查不到。
|
||||||
|
const rec = await c.getRecord(ent.id);
|
||||||
|
expect(rec?.values.owner).toBe('leo');
|
||||||
|
expect(await findEntityByName(c, 'inkstone', 'someone-else')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('漏 owner → 插件端 fail(不寫出無主 entity)', async () => {
|
||||||
|
const c = mockClient();
|
||||||
|
await expect(
|
||||||
|
// @ts-expect-error 蓄意漏 owner:型別上 owner 必填,執行期也應 throw
|
||||||
|
createEntity(c, 'NoOwner'),
|
||||||
|
).rejects.toThrow(/owner/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('listEntities 列出', async () => {
|
it('listEntities 列出', async () => {
|
||||||
const c = mockClient();
|
const c = mockClient();
|
||||||
await createEntity(c, 'A');
|
await createEntity(c, 'A', 'leo');
|
||||||
await createEntity(c, 'B');
|
await createEntity(c, 'B', 'leo');
|
||||||
const all = await listEntities(c);
|
const all = await listEntities(c);
|
||||||
expect(all.map((e) => e.canonical).sort()).toEqual(['A', 'B']);
|
expect(all.map((e) => e.canonical).sort()).toEqual(['A', 'B']);
|
||||||
});
|
});
|
||||||
@@ -24,8 +36,8 @@ describe('entity-crud', () => {
|
|||||||
describe('normalizeEntity', () => {
|
describe('normalizeEntity', () => {
|
||||||
it('已存在回 canonical,不存在建新回原值', async () => {
|
it('已存在回 canonical,不存在建新回原值', async () => {
|
||||||
const c = mockClient();
|
const c = mockClient();
|
||||||
await createEntity(c, 'InkStone');
|
await createEntity(c, 'InkStone', 'leo');
|
||||||
expect(await normalizeEntity(c, 'INKSTONE')).toBe('InkStone');
|
expect(await normalizeEntity(c, 'INKSTONE', 'leo')).toBe('InkStone');
|
||||||
expect(await normalizeEntity(c, '新公司')).toBe('新公司');
|
expect(await normalizeEntity(c, '新公司', 'leo')).toBe('新公司');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ describe('getSource — 回節點的原文來源指標', () => {
|
|||||||
source: { uri: 'github:u/w@a.md', content_hash: 'h1', anchor: '#graph-rag' },
|
source: { uri: 'github:u/w@a.md', content_hash: 'h1', anchor: '#graph-rag' },
|
||||||
extractor: { model: 'm', tier: 'deep' },
|
extractor: { model: 'm', tier: 'deep' },
|
||||||
triplets: [{ subject: 'GraphRAG', predicate: '是', object: 'RAG 變體' }],
|
triplets: [{ subject: 'GraphRAG', predicate: '是', object: 'RAG 變體' }],
|
||||||
});
|
}, 'leo');
|
||||||
|
|
||||||
const refs = await getSource(c, 'GraphRAG');
|
const refs = await getSource(c, 'GraphRAG');
|
||||||
expect(refs.length).toBe(1);
|
expect(refs.length).toBe(1);
|
||||||
@@ -27,12 +27,12 @@ describe('getSource — 回節點的原文來源指標', () => {
|
|||||||
source: { uri: 'github:u/w@a.md', content_hash: 'h1', anchor: '#old' },
|
source: { uri: 'github:u/w@a.md', content_hash: 'h1', anchor: '#old' },
|
||||||
extractor: { model: 'm', tier: 'deep' },
|
extractor: { model: 'm', tier: 'deep' },
|
||||||
triplets: [{ subject: 'X', predicate: 'r', object: 'old' }],
|
triplets: [{ subject: 'X', predicate: 'r', object: 'old' }],
|
||||||
});
|
}, 'leo');
|
||||||
await ingestEnvelope(c, {
|
await ingestEnvelope(c, {
|
||||||
source: { uri: 'github:u/w@a.md', content_hash: 'h2', anchor: '#new' },
|
source: { uri: 'github:u/w@a.md', content_hash: 'h2', anchor: '#new' },
|
||||||
extractor: { model: 'm', tier: 'deep' },
|
extractor: { model: 'm', tier: 'deep' },
|
||||||
triplets: [{ subject: 'X', predicate: 'r', object: 'new' }],
|
triplets: [{ subject: 'X', predicate: 'r', object: 'new' }],
|
||||||
});
|
}, 'leo');
|
||||||
|
|
||||||
const refs = await getSource(c, 'X');
|
const refs = await getSource(c, 'X');
|
||||||
expect(refs.length).toBe(1);
|
expect(refs.length).toBe(1);
|
||||||
|
|||||||
+4
-4
@@ -7,10 +7,10 @@ import { mockClient } from './mock-client';
|
|||||||
import type { KbdbClient } from '../src/lib/kbdb-client';
|
import type { KbdbClient } from '../src/lib/kbdb-client';
|
||||||
|
|
||||||
async function seed(c: KbdbClient) {
|
async function seed(c: KbdbClient) {
|
||||||
// A — B — C,D 孤立連 A
|
// A — B — C,D 孤立連 A(owner 必經:所有寫入帶真 owner)
|
||||||
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B' });
|
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B', owner_id: 'leo' });
|
||||||
await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C' });
|
await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C', owner_id: 'leo' });
|
||||||
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D' });
|
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D', owner_id: 'leo' });
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('graph-nodes', () => {
|
describe('graph-nodes', () => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export class MockKbdbClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createEntry(input: any): Promise<BaseEntry> {
|
async createEntry(input: any): Promise<BaseEntry> {
|
||||||
|
// 對齊基本盤 D27/D28 即將上線的 owner 必填:缺 owner → 400(模擬 base 擋死無主寫入)。
|
||||||
|
if (!(input?.owner_id ?? '').trim()) throw new Error('[kbdb-base] POST /entries: owner_id required (400)');
|
||||||
const id = this.id('entry');
|
const id = this.id('entry');
|
||||||
const entry: BaseEntry = { id, content: input.content ?? null, entry_type: input.entry_type, owner_id: input.owner_id ?? null };
|
const entry: BaseEntry = { id, content: input.content ?? null, entry_type: input.entry_type, owner_id: input.owner_id ?? null };
|
||||||
this.entries.set(id, entry);
|
this.entries.set(id, entry);
|
||||||
@@ -61,6 +63,8 @@ export class MockKbdbClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createRecord(template: string, values: Record<string, string>, owner_id?: string): Promise<string> {
|
async createRecord(template: string, values: Record<string, string>, owner_id?: string): Promise<string> {
|
||||||
|
// 對齊基本盤 D27/D28 即將上線的 owner 必填:缺 owner → 400(模擬 base 擋死無主寫入)。
|
||||||
|
if (!(owner_id ?? '').trim()) throw new Error('[kbdb-base] POST /records: owner_id required (400)');
|
||||||
const id = this.id('rec');
|
const id = this.id('rec');
|
||||||
this.records.set(id, { template, values: { ...values }, owner_id });
|
this.records.set(id, { template, values: { ...values }, owner_id });
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { mockClient } from './mock-client';
|
|||||||
describe('createTriplet → records API', () => {
|
describe('createTriplet → records API', () => {
|
||||||
it('建立後可由 id 取回', async () => {
|
it('建立後可由 id 取回', async () => {
|
||||||
const c = mockClient();
|
const c = mockClient();
|
||||||
const r = await createTriplet(c, { subject: 'InkStone', predicate: '是', object: '創業 OS' });
|
const r = await createTriplet(c, { subject: 'InkStone', predicate: '是', object: '創業 OS', owner_id: 'leo' });
|
||||||
expect(r.id).toBeDefined();
|
expect(r.id).toBeDefined();
|
||||||
expect(r.subject).toBe('InkStone');
|
expect(r.subject).toBe('InkStone');
|
||||||
|
|
||||||
@@ -14,13 +14,23 @@ describe('createTriplet → records API', () => {
|
|||||||
expect(got?.predicate).toBe('是');
|
expect(got?.predicate).toBe('是');
|
||||||
expect(got?.object).toBe('創業 OS');
|
expect(got?.object).toBe('創業 OS');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('漏 owner → 插件端 fail(不靜默送空,不寫出無主 triplet)', async () => {
|
||||||
|
const c = mockClient();
|
||||||
|
await expect(
|
||||||
|
// @ts-expect-error 蓄意漏 owner:型別上 owner 必填,執行期也應 throw
|
||||||
|
createTriplet(c, { subject: 'X', predicate: 'p', object: 'Y' }),
|
||||||
|
).rejects.toThrow(/owner/i);
|
||||||
|
const { count } = await queryTriplets(c, { includeDeprecated: true });
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('queryTriplets → 插件層 filter', () => {
|
describe('queryTriplets → 插件層 filter', () => {
|
||||||
it('by subject 過濾', async () => {
|
it('by subject 過濾', async () => {
|
||||||
const c = mockClient();
|
const c = mockClient();
|
||||||
await createTriplet(c, { subject: 'KBDB', predicate: '使用', object: 'D1' });
|
await createTriplet(c, { subject: 'KBDB', predicate: '使用', object: 'D1', owner_id: 'leo' });
|
||||||
await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X' });
|
await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X', owner_id: 'leo' });
|
||||||
|
|
||||||
const { triplets, count } = await queryTriplets(c, { subject: 'KBDB' });
|
const { triplets, count } = await queryTriplets(c, { subject: 'KBDB' });
|
||||||
expect(count).toBe(1);
|
expect(count).toBe(1);
|
||||||
@@ -29,7 +39,7 @@ describe('queryTriplets → 插件層 filter', () => {
|
|||||||
|
|
||||||
it('limit/offset 分頁', async () => {
|
it('limit/offset 分頁', async () => {
|
||||||
const c = mockClient();
|
const c = mockClient();
|
||||||
for (let i = 0; i < 5; i++) await createTriplet(c, { subject: `s${i}`, predicate: 'p', object: 'o' });
|
for (let i = 0; i < 5; i++) await createTriplet(c, { subject: `s${i}`, predicate: 'p', object: 'o', owner_id: 'leo' });
|
||||||
const { triplets } = await queryTriplets(c, { limit: 2, offset: 1 });
|
const { triplets } = await queryTriplets(c, { limit: 2, offset: 1 });
|
||||||
expect(triplets.length).toBe(2);
|
expect(triplets.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// ingest 寫入端 — 走 mock KbdbClient(API-as-Wall),零 SQL、不打網路。
|
// ingest 寫入端 — 走 mock KbdbClient(API-as-Wall),零 SQL、不打網路。
|
||||||
// 覆蓋 T3.4 五案:正常 envelope / 同 hash no-op / 新 hash deprecate / 污染 envelope 422 / rollback。
|
// 覆蓋 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 { describe, it, expect } from 'vitest';
|
||||||
import { ingestEnvelope, IngestEnvelopeSchema, type IngestEnvelope } from '../src/actions/triplet-ingest';
|
import { ingestEnvelope, IngestEnvelopeSchema, type IngestEnvelope } from '../src/actions/triplet-ingest';
|
||||||
import { queryTriplets } from '../src/actions/triplet-crud';
|
import { queryTriplets } from '../src/actions/triplet-crud';
|
||||||
import { mockClient } from './mock-client';
|
import { mockClient } from './mock-client';
|
||||||
|
|
||||||
|
const OWNER = 'leo';
|
||||||
|
|
||||||
function envelope(hash: string, triplets: IngestEnvelope['triplets']): IngestEnvelope {
|
function envelope(hash: string, triplets: IngestEnvelope['triplets']): IngestEnvelope {
|
||||||
return {
|
return {
|
||||||
source: { uri: 'github:uncle6me-web/wiki@a.md', content_hash: hash },
|
source: { uri: 'github:uncle6me-web/wiki@a.md', content_hash: hash },
|
||||||
@@ -19,7 +22,7 @@ describe('ingestEnvelope — 正常 envelope', () => {
|
|||||||
const res = await ingestEnvelope(c, envelope('h1', [
|
const res = await ingestEnvelope(c, envelope('h1', [
|
||||||
{ subject: 'A', predicate: 'rel', object: 'B' },
|
{ subject: 'A', predicate: 'rel', object: 'B' },
|
||||||
{ subject: 'B', predicate: 'rel', object: 'C' },
|
{ subject: 'B', predicate: 'rel', object: 'C' },
|
||||||
]));
|
]), OWNER);
|
||||||
expect(res).toEqual({ skipped: false, ingested: 2, deprecated: 0 });
|
expect(res).toEqual({ skipped: false, ingested: 2, deprecated: 0 });
|
||||||
|
|
||||||
const { triplets } = await queryTriplets(c, {});
|
const { triplets } = await queryTriplets(c, {});
|
||||||
@@ -30,11 +33,46 @@ describe('ingestEnvelope — 正常 envelope', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 帶真 owner(owner 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', () => {
|
describe('ingestEnvelope — 同 hash no-op', () => {
|
||||||
it('同 uri+hash 再送 → skipped,不新增', async () => {
|
it('同 uri+hash 再送 → skipped,不新增', async () => {
|
||||||
const c = mockClient();
|
const c = mockClient();
|
||||||
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]));
|
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' }]));
|
const res = await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]), OWNER);
|
||||||
expect(res.skipped).toBe(true);
|
expect(res.skipped).toBe(true);
|
||||||
|
|
||||||
const { triplets } = await queryTriplets(c, {});
|
const { triplets } = await queryTriplets(c, {});
|
||||||
@@ -45,8 +83,8 @@ describe('ingestEnvelope — 同 hash no-op', () => {
|
|||||||
describe('ingestEnvelope — 新 hash deprecate-then-append', () => {
|
describe('ingestEnvelope — 新 hash deprecate-then-append', () => {
|
||||||
it('同 uri 新 hash → 舊批轉 deprecated、新批 active;查詢 active-only', async () => {
|
it('同 uri 新 hash → 舊批轉 deprecated、新批 active;查詢 active-only', async () => {
|
||||||
const c = mockClient();
|
const c = mockClient();
|
||||||
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]));
|
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' }]));
|
const res = await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]), OWNER);
|
||||||
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 1 });
|
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 1 });
|
||||||
|
|
||||||
// active-only 查詢只見新批。
|
// active-only 查詢只見新批。
|
||||||
@@ -103,7 +141,7 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
|
|||||||
expect(IngestEnvelopeSchema.safeParse(env).success).toBe(true);
|
expect(IngestEnvelopeSchema.safeParse(env).success).toBe(true);
|
||||||
|
|
||||||
// 落地:triplet 寫入、node 打標存進 entity slot。
|
// 落地:triplet 寫入、node 打標存進 entity slot。
|
||||||
const res = await ingestEnvelope(c, env);
|
const res = await ingestEnvelope(c, env, OWNER);
|
||||||
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 0 });
|
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 0 });
|
||||||
|
|
||||||
const { triplets } = await queryTriplets(c, {});
|
const { triplets } = await queryTriplets(c, {});
|
||||||
@@ -131,7 +169,7 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
|
|||||||
],
|
],
|
||||||
triplets: [{ subject: 'Graph RAG', predicate: 'r', object: 'X' }],
|
triplets: [{ subject: 'Graph RAG', predicate: 'r', object: 'X' }],
|
||||||
};
|
};
|
||||||
await ingestEnvelope(c, env);
|
await ingestEnvelope(c, env, OWNER);
|
||||||
const entities = await c.listRecordsByTemplate('entity');
|
const entities = await c.listRecordsByTemplate('entity');
|
||||||
expect(entities.filter((e) => e.values.node_id === 'graph-rag.md').length).toBe(1);
|
expect(entities.filter((e) => e.values.node_id === 'graph-rag.md').length).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -160,8 +198,8 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
|
|||||||
describe('ingestEnvelope — rollback(翻回 status)', () => {
|
describe('ingestEnvelope — rollback(翻回 status)', () => {
|
||||||
it('把 deprecated 翻回 active 後,active 查詢重新見到它', async () => {
|
it('把 deprecated 翻回 active 後,active 查詢重新見到它', async () => {
|
||||||
const c = mockClient();
|
const c = mockClient();
|
||||||
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]));
|
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]), OWNER);
|
||||||
await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]));
|
await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]), OWNER);
|
||||||
|
|
||||||
// 取出被 deprecate 的舊批 id,手動 rollback(翻回 active、清 superseded_by)。
|
// 取出被 deprecate 的舊批 id,手動 rollback(翻回 active、清 superseded_by)。
|
||||||
const all = await queryTriplets(c, { includeDeprecated: true });
|
const all = await queryTriplets(c, { includeDeprecated: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user