Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cc7057cea | |||
| b53c5c94a9 | |||
| 454ea8d240 | |||
| c7d8a74bee | |||
| 01f131a7a2 | |||
| 13db97bb54 |
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"description": "節點層附帶資訊(選填)。entity_type 與 gloss 是【節點】屬性,不是【邊】屬性 → 放這裡,不放 triplets。graph 用 gloss 去 embed(每節點一句,不是裸詞)、用 entity_type 去 typing。",
|
||||
"description": "節點層附帶資訊。【向量化分工(leo 2026-06-26,ingest#1 升格成契約)】ingest 在此【打標】哪些 token 要向量化 + embed 什麼;base/KBDB embed 模組【讀標執行】實際 embedding;ingest 自己不算向量。兩類節點(實體詞條 / wikilink 卡)都進 nodes[],謂詞向量見 triplets[].predicate_vector。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
@@ -69,11 +69,26 @@
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "節點名(須對應某 triplet 的 subject 或 object 原字面)。"
|
||||
"description": "節點名(須對應某 triplet 的 subject/object 原字面)。實體詞條=正規名;wikilink 卡=卡標題。"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "去重鍵。wikilink 卡用【檔名】→ 一卡一 node,被多條邊指到也只 embed 一次,不以出現次數重複。實體詞條用正規名。選填(無則以 name 去重)。"
|
||||
},
|
||||
"gloss": {
|
||||
"type": "string",
|
||||
"description": "一句話描述,供 embedding。例如 'Graph RAG — 用關係遍歷檢索、保住異見的 RAG 變體'。選填(建議 deep tier 產出)。"
|
||||
"description": "一句話描述。base embed 對【名 + gloss 一起】embedding(實體同義詞字面差太遠,靠描述拉近)。選填(建議 deep tier 產)。"
|
||||
},
|
||||
"aliases": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "同義詞(如『黃仁勳』/『Jensen Huang』)。base 歸一(collapse)成同一 node。選填。"
|
||||
},
|
||||
"embed": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "【向量化打標】此節點要不要進向量庫。true=base 讀標去 embed(名+gloss);false=base 看到就不理(如結構符號/散文不該進 nodes[],真進了標 false)。預設 true(實體詞條與 wikilink 卡都要)。",
|
||||
"$comment": "ingest 打標,base 讀標執行。embed 動作歸 base embed 模組,ingest 不算向量。"
|
||||
},
|
||||
"entity_type": {
|
||||
"type": "string",
|
||||
@@ -86,7 +101,7 @@
|
||||
"triplets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "邊(關係)。ingest 只產原始 (s,p,o) + confidence。",
|
||||
"description": "邊(關係)。ingest 只產原始 (s,p,o) + confidence + 謂詞向量打標。端點(s/o)以字面 match nodes[].name。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["subject", "predicate", "object"],
|
||||
@@ -95,10 +110,16 @@
|
||||
"subject": { "type": "string", "minLength": 1, "description": "主詞(實體名,須與 nodes[].name 對得上若有提供)" },
|
||||
"predicate": { "type": "string", "minLength": 1, "description": "謂詞(關係)" },
|
||||
"object": { "type": "string", "minLength": 1, "description": "受詞(目標實體或值)" },
|
||||
"predicate_embed": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "【謂詞向量化打標】謂詞要不要 embed。base 讀標 → embed【謂詞裸詞,無描述】(謂詞同義詞字面本就近,如『參考』/『參照』,裸詞 embed 即自動聚類),存 edge 的 predicate_vector。為支援『關係過濾』查詢(查『參考』不漏『參照』)→ 預設 true。embed 動作歸 base,ingest 只打標。",
|
||||
"$comment": "ingest 打標,base 讀標執行 embed。"
|
||||
},
|
||||
"confidence":{ "type": "number", "minimum": 0, "maximum": 1, "default": 1.0, "description": "萃取可信度。淺萃可附自評;graph 不據此過濾,只記錄。" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$comment": "禁止欄位(graph 領域,ingest 絕不可送): id / clusters / bridge_score / created_at / updated_at / 以及 triplet 上的 subject_entity_type|object_entity_type(類型只走 nodes[])。送了即違反 ingest=純餵食器的邊界,graph 應拒收或忽略。"
|
||||
"$comment": "禁止欄位(graph 領域,ingest 絕不可送): id(節點去重鍵的 id 例外,那是 ingest 提供的去重鍵非 record id) / clusters / bridge_score / created_at / updated_at / 以及 triplet 上的 subject_entity_type|object_entity_type(類型只走 nodes[])。【向量化分工】ingest 打標(embed/predicate_embed + 帶 gloss/aliases),base/KBDB embed 模組讀標執行 embedding,ingest 不算向量。結構符號(>>/←)與給人讀的散文(## 摘要)不進 envelope。"
|
||||
}
|
||||
|
||||
Generated
-1686
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
// 存量回填:對「已存在的 entity records」補上對應的 embeddable base entry。
|
||||
// 讀 base 既有 entity records(GET /records/by-template/entity)→ 為每筆有 gloss 的落一筆 embeddable entry
|
||||
// (同 persistNodes 前向路徑的格式與冪等)。讓既有知識立刻可被 base embed backfill 嵌到。
|
||||
// 鐵律:走 base API(API-as-Wall)、零 SQL、樂高法(<100 行、一檔一事、第一參數收 KbdbClient)。
|
||||
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { TPL_ENTITY, ensurePluginTemplates } from '../lib/templates';
|
||||
import { upsertGlossEntry, type GlossEntryOutcome } from './gloss-entry';
|
||||
|
||||
export type BackfillGlossResult = {
|
||||
scanned: number; // 掃到的 entity record 數
|
||||
created: number; // 新落的 embeddable entry 數
|
||||
updated: number; // gloss 變 → 更新內容的數
|
||||
unchanged: number; // 已存在且內容相同(冪等 no-op)
|
||||
skipped: number; // embed=false / 空 gloss / 空 canonical → 不落
|
||||
};
|
||||
|
||||
/**
|
||||
* 對既有 entity records 補 embeddable entry。冪等:重跑不造重複(同 node → 同 page_name → no-op)。
|
||||
* embed 標為 'false' 的 record 略過(明確不入向量庫)。source 未知(存量)→ 留空。
|
||||
*/
|
||||
export async function backfillGlossEntries(
|
||||
client: KbdbClient,
|
||||
owner_id?: string,
|
||||
): Promise<BackfillGlossResult> {
|
||||
await ensurePluginTemplates(client);
|
||||
const records = await client.listRecordsByTemplate(TPL_ENTITY, owner_id);
|
||||
const res: BackfillGlossResult = { scanned: records.length, created: 0, updated: 0, unchanged: 0, skipped: 0 };
|
||||
|
||||
for (const r of records) {
|
||||
const v = r.values;
|
||||
if (v.embed === 'false') { res.skipped++; continue; } // 明確標不嵌 → 略過
|
||||
const outcome: GlossEntryOutcome = await upsertGlossEntry(
|
||||
client,
|
||||
{ canonical: v.canonical ?? '', node_id: v.node_id || '', gloss: v.gloss ?? '', source: '' },
|
||||
owner_id,
|
||||
);
|
||||
res[outcome]++; // 'created' | 'updated' | 'unchanged' | 'skipped' 都是 res 的 number 欄位
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// node gloss → base embeddable entry。把 node 的 canonical+gloss 落成一筆 base entry,
|
||||
// metadata.embed=true,供 base optional embed 模組讀標 → 嵌進 Vectorize(見 arcrun/kbdb src/embed.ts)。
|
||||
// 鐵律:走 base API(API-as-Wall)、零 SQL、不綁 Vectorize(embedding 是 base 職責,graph 只落標)。
|
||||
//
|
||||
// 為何要這檔:node 打標存進 entity **record**(node-persist),但 base embed 只掃 **entries**.metadata_json
|
||||
// .$.embed===true → 標在 record、讀在 entry 對不上,語意查不到圖內容。這裡補「同一 gloss 也落成 entry」。
|
||||
//
|
||||
// 冪等(base 無 upsert entry 端點):以 node 去重鍵派生確定性 page_name(base 文件化的 idempotency key),
|
||||
// 先 listEntries({entry_type,page_name}) 查存 → 無則 create、內容變則 patch、同則 no-op。
|
||||
// [→arcrun] 若 base 日後補「POST /entries upsert(page_name 為鍵)」,這裡可收斂成單一呼叫、免 list-then-write。
|
||||
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
|
||||
// base embed 對「內容語意」無知,只認通用 embed 旗標;entry_type 供向量 metadata 過濾/辨識這批是圖節點 gloss。
|
||||
export const NODE_GLOSS_ENTRY_TYPE = 'graph_node_gloss';
|
||||
|
||||
export type GlossEntryInput = {
|
||||
canonical: string;
|
||||
node_id?: string;
|
||||
gloss?: string;
|
||||
source?: string; // 沿用 node 的 ingest 來源(envelope source.uri);存量回填未知則留空
|
||||
};
|
||||
|
||||
export type GlossEntryOutcome = 'created' | 'updated' | 'unchanged' | 'skipped';
|
||||
|
||||
/** node 去重鍵(同 persistNodes:id 優先、無則 canonical)→ 確定性 idempotency key。 */
|
||||
function glossKey(canonical: string, node_id?: string): string {
|
||||
return `gloss:${(node_id || canonical).toLowerCase().trim()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一個 node 的 gloss 落成 embeddable base entry(冪等)。
|
||||
* - 空 gloss(或空 canonical)→ 跳過:base 對空 content 本就跳過 embed,且裸名的語意召回價值低、
|
||||
* keyword 搜尋已覆蓋 → 不造無意義 entry(契約允許「空 gloss 可跳過」)。
|
||||
* - content = `canonical:gloss`(名+描述一起 embed,利於語意召回)。
|
||||
* - metadata_json.embed=true(base 讀此旗標)、source、node_id、canonical(回連原 node)。
|
||||
*/
|
||||
export async function upsertGlossEntry(
|
||||
client: KbdbClient,
|
||||
node: GlossEntryInput,
|
||||
owner_id?: string,
|
||||
): Promise<GlossEntryOutcome> {
|
||||
const canonical = (node.canonical || '').trim();
|
||||
const gloss = (node.gloss || '').trim();
|
||||
if (!canonical || !gloss) return 'skipped';
|
||||
|
||||
const page_name = glossKey(canonical, node.node_id);
|
||||
const content = `${canonical}:${gloss}`;
|
||||
const metadata_json = JSON.stringify({
|
||||
embed: true,
|
||||
source: node.source || '',
|
||||
node_id: node.node_id || '',
|
||||
canonical,
|
||||
});
|
||||
|
||||
// 確定性 key 去重:同一 node 重複 ingest 不造重複 entry。
|
||||
const existing = await client.listEntries({ entry_type: NODE_GLOSS_ENTRY_TYPE, page_name, owner_id });
|
||||
if (existing.length > 0) {
|
||||
const e = existing[0];
|
||||
if (e.content === content) return 'unchanged'; // 同內容 → no-op
|
||||
await client.updateEntry(e.id, { content, metadata_json }); // gloss 變 → 更新(base PATCH 觸發重嵌)
|
||||
return 'updated';
|
||||
}
|
||||
await client.createEntry({ content, entry_type: NODE_GLOSS_ENTRY_TYPE, owner_id, page_name, metadata_json });
|
||||
return 'created';
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// node 層打標落地 — 把 envelope nodes[] 的向量化打標(embed/gloss/aliases)存進 entity slot。
|
||||
// 向量化分工(ingest#1 升格,2026-06-26):ingest 打標、base/KBDB embed 模組讀標執行;graph 不算向量。
|
||||
// 鐵律:走 base API(API-as-Wall)、零 SQL。
|
||||
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { TPL_ENTITY, ensurePluginTemplates } from '../lib/templates';
|
||||
import { upsertGlossEntry } from './gloss-entry';
|
||||
|
||||
export type IngestNode = {
|
||||
name: string;
|
||||
id?: string;
|
||||
aliases?: string[];
|
||||
gloss?: string;
|
||||
embed?: boolean;
|
||||
entity_type?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 把 node 層打標存進 entity record,供 base embed 模組讀標執行 embedding。
|
||||
* 去重:以 id(無則 name)為鍵,同鍵在這批內只存一筆——wikilink 卡被多條邊指到仍是一個 node。
|
||||
* graph 不做 embedding,只負責透傳/落地打標。
|
||||
*/
|
||||
export async function persistNodes(
|
||||
client: KbdbClient,
|
||||
nodes: IngestNode[],
|
||||
owner_id?: string,
|
||||
source?: string, // envelope source.uri,帶進 gloss entry 的 metadata.source(供 base backfill 依 source 過濾)
|
||||
): Promise<void> {
|
||||
if (!nodes || nodes.length === 0) return;
|
||||
await ensurePluginTemplates(client);
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const n of nodes) {
|
||||
const key = (n.id ?? n.name).toLowerCase().trim();
|
||||
if (seen.has(key)) continue; // 同卡多邊指到 → 只存一次
|
||||
seen.add(key);
|
||||
await client.createRecord(
|
||||
TPL_ENTITY,
|
||||
{
|
||||
canonical: n.name,
|
||||
node_id: n.id ?? '',
|
||||
aliases_json: JSON.stringify(n.aliases ?? []),
|
||||
entity_type: n.entity_type ?? '',
|
||||
gloss: n.gloss ?? '',
|
||||
// contract 預設 true;只在明確 false 時存標(base 看 'false' 跳過 embed)。
|
||||
embed: n.embed === false ? 'false' : 'true',
|
||||
owner: owner_id ?? '',
|
||||
},
|
||||
owner_id,
|
||||
);
|
||||
|
||||
// 另落一筆 embeddable base entry(metadata.embed=true)——record 的 gloss 標 base embed 讀不到,
|
||||
// 必須也落成 entry base embed 模組才掃得到(打標≠讀標的修補)。只在要 embed(embed !== false)時落;
|
||||
// 空 gloss 由 upsertGlossEntry 自行跳過。冪等:同 node 重複 ingest 不造重複 entry。
|
||||
if (n.embed !== false) {
|
||||
await upsertGlossEntry(client, { canonical: n.name, node_id: n.id, gloss: n.gloss, source }, owner_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export type CreateTripletData = {
|
||||
source_uri?: string;
|
||||
content_hash?: string;
|
||||
source_anchor?: string;
|
||||
predicate_embed?: boolean; // 謂詞向量化打標(ingest 打標、base 讀標執行);graph 不算向量
|
||||
};
|
||||
|
||||
/** 建立三元組 → POST /records(template=triplet)。 */
|
||||
@@ -48,6 +49,7 @@ export async function createTriplet(
|
||||
if (data.source_uri) values.source_uri = data.source_uri;
|
||||
if (data.content_hash) values.content_hash = data.content_hash;
|
||||
if (data.source_anchor) values.source_anchor = data.source_anchor;
|
||||
if (data.predicate_embed === false) values.predicate_embed = 'false'; // 謂詞向量化打標透傳,base 讀標執行
|
||||
|
||||
const id = await client.createRecord(TPL_TRIPLET, values, data.owner_id);
|
||||
return { id, subject: data.subject, predicate: data.predicate, object: data.object };
|
||||
|
||||
@@ -6,11 +6,17 @@ 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();
|
||||
|
||||
@@ -18,6 +24,7 @@ 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();
|
||||
|
||||
@@ -59,13 +66,14 @@ export async function ingestEnvelope(
|
||||
return { skipped: true, ingested: 0, deprecated: 0 };
|
||||
}
|
||||
|
||||
// 1) 先 append 新批 active。
|
||||
// 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,
|
||||
@@ -74,6 +82,11 @@ export async function ingestEnvelope(
|
||||
});
|
||||
}
|
||||
|
||||
// 1b) 落地 node 層打標(embed / gloss / aliases),供 base embed 模組讀標執行 embedding。
|
||||
// graph 自己不算向量(鐵律一致)。id 作去重鍵:同一卡(同 id/檔名)只存一筆 entity,不以邊數重複。
|
||||
// 並為每個 node 另落一筆 embeddable base entry(source.uri 帶進 metadata.source)。
|
||||
await persistNodes(client, env.nodes ?? [], owner_id, env.source.uri);
|
||||
|
||||
// 2) 後翻舊批 status=deprecated(指向本批 source_uri;append 在前 → 無空窗)。
|
||||
for (const old of priorActive) {
|
||||
await client.updateRecord(old.id, { status: 'deprecated', superseded_by: env.source.content_hash });
|
||||
|
||||
+38
-8
@@ -13,6 +13,9 @@ export type BaseEntry = {
|
||||
owner_id: string | null;
|
||||
parent_id?: string | null;
|
||||
page_name?: string | null;
|
||||
// base entries 的通用 metadata(TEXT,JSON 字串)。base embed 模組讀 metadata_json.$.embed / $.source
|
||||
// 決定要不要嵌、嵌進哪個 source 分組(見 arcrun/kbdb src/embed.ts)。插件只落標、不算向量。
|
||||
metadata_json?: string | null;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
};
|
||||
@@ -28,7 +31,10 @@ export type CreateEntryInput = {
|
||||
entry_type: string;
|
||||
owner_id?: string;
|
||||
parent_id?: string;
|
||||
// page_name = base 文件化的 idempotency key(entry-crud.ts:exact-match lookup)。
|
||||
page_name?: string;
|
||||
// metadata_json = base 既有欄位(JSON 字串)。標 { embed:true, source, ... } 供 base embed 模組讀標執行。
|
||||
metadata_json?: string;
|
||||
};
|
||||
|
||||
/** 基本盤 API client。所有方法 = 一個 HTTP 呼叫,零 SQL。 */
|
||||
@@ -36,6 +42,12 @@ export class KbdbClient {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
private readonly token?: string,
|
||||
// 2026-07-03 補跑實測發現的坑:Cloudflare 會擋 Worker → 另一個 *.workers.dev
|
||||
// Worker 的直連 fetch(error code 1042,loop-prevention on shared workers.dev zone)。
|
||||
// self-hosted 帳號通常沒有自訂域名可用,正規解法 = Service Binding
|
||||
// (wrangler.toml `[[services]]`),由 CF 內部直接路由、不經公開網路。
|
||||
// 有綁定時優先走它;沒有(例如本地 dev/mock)則 fallback 回全域 fetch。
|
||||
private readonly fetcher?: { fetch: typeof fetch },
|
||||
) {
|
||||
if (!baseUrl) {
|
||||
throw new Error('KBDB_BASE_URL 未設定:插件需指向基本盤 API(不可直連 D1)');
|
||||
@@ -46,7 +58,8 @@ export class KbdbClient {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
|
||||
const res = await fetch(this.baseUrl.replace(/\/$/, '') + path, {
|
||||
const doFetch = this.fetcher ? this.fetcher.fetch.bind(this.fetcher) : fetch;
|
||||
const res = await doFetch(this.baseUrl.replace(/\/$/, '') + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
@@ -113,23 +126,34 @@ export class KbdbClient {
|
||||
// --- templates(= 替代建表;插件要新類型只能建 template) ---
|
||||
|
||||
async ensureTemplate(name: string, slots: string[], description?: string): Promise<void> {
|
||||
const existing = await this.req<{ id?: string; slots?: string[] } | { error: string }>(
|
||||
// 2026-07-03 補跑實測發現的坑:base 的 GET /templates/:name 回傳是
|
||||
// { success, template: { id, slots_json: "[...]" } }(包一層 + slots 是 JSON 字串),
|
||||
// 不是原本假設的 { id, slots } 平鋪陣列。誤判「不存在」會導致對已存在的 name 重複
|
||||
// POST /templates,而 base 對重名衝突沒有回優雅的 409,是直接 500(見 kbdb-graph 實測)。
|
||||
const existing = await this.req<{ template?: { id: string; slots_json?: string } }>(
|
||||
'GET',
|
||||
`/templates/${encodeURIComponent(name)}`,
|
||||
).catch(() => null);
|
||||
const tpl = existing?.template;
|
||||
|
||||
// 全新 template → 建。
|
||||
if (!existing || !(existing as any).id) {
|
||||
if (!tpl || !tpl.id) {
|
||||
await this.req('POST', '/templates', { name, slots, description, created_by: 'kbdb-graph' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 既有 template → 補缺 slot(不 early-return;否則 seed 後新增的 slot 永遠進不來)。
|
||||
// 走 base PATCH /templates/:id 增 slot;既有環境免另跑遷移腳本即收斂。
|
||||
const have = new Set((existing as any).slots ?? []);
|
||||
let haveList: string[] = [];
|
||||
try {
|
||||
haveList = JSON.parse(tpl.slots_json ?? '[]');
|
||||
} catch {
|
||||
haveList = [];
|
||||
}
|
||||
const have = new Set(haveList);
|
||||
const missing = slots.filter((s) => !have.has(s));
|
||||
if (missing.length === 0) return;
|
||||
await this.req('PATCH', `/templates/${encodeURIComponent((existing as any).id)}`, {
|
||||
await this.req('PATCH', `/templates/${encodeURIComponent(tpl.id)}`, {
|
||||
slots: [...have, ...missing],
|
||||
});
|
||||
}
|
||||
@@ -175,7 +199,13 @@ function qs(params: Record<string, string | number | undefined>): string {
|
||||
return parts.length ? `?${parts.join('&')}` : '';
|
||||
}
|
||||
|
||||
/** 從 Bindings 建 client。KBDB_BASE_URL 未設時拋錯(不准 fallback 直連 D1)。 */
|
||||
export function makeKbdbClient(env: { KBDB_BASE_URL?: string; KBDB_INTERNAL_TOKEN?: string }): KbdbClient {
|
||||
return new KbdbClient(env.KBDB_BASE_URL ?? '', env.KBDB_INTERNAL_TOKEN);
|
||||
/** 從 Bindings 建 client。KBDB_BASE_URL 未設時拋錯(不准 fallback 直連 D1)。
|
||||
* 有 KBDB_BASE_SVC(service binding)時優先走它,繞開 workers.dev→workers.dev 的
|
||||
* CF error 1042 封鎖;沒有就退回全域 fetch(本地 dev / 已有自訂域名時仍可用)。 */
|
||||
export function makeKbdbClient(env: {
|
||||
KBDB_BASE_URL?: string;
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
KBDB_BASE_SVC?: { fetch: typeof fetch };
|
||||
}): KbdbClient {
|
||||
return new KbdbClient(env.KBDB_BASE_URL ?? '', env.KBDB_INTERNAL_TOKEN, env.KBDB_BASE_SVC);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,13 @@ export const TRIPLET_SLOTS = [
|
||||
// source_uri+content_hash 承載 ingest idempotency(按 source_uri 分組 deprecate)。
|
||||
// source_anchor 供 get_source 精準回跳原文(T3.7)。
|
||||
'status', 'superseded_by', 'source_uri', 'content_hash', 'source_anchor',
|
||||
// 謂詞向量化打標(ingest#1 升格,2026-06-26):ingest 打標、base embed 模組讀標執行;graph 不算向量。
|
||||
'predicate_embed',
|
||||
];
|
||||
// gloss(T3.2b):一句話描述,供「詞+gloss」語義 normalize 的 embedding 對象。
|
||||
export const ENTITY_SLOTS = ['canonical', 'aliases_json', 'entity_type', 'owner', 'gloss'];
|
||||
// embed(ingest#1 升格,2026-06-26):向量化打標,base embed 模組讀標執行;graph 不算向量。
|
||||
// node_id(去重鍵):wikilink 卡用檔名,一卡一 node、多邊指到只 embed 一次(base 讀此鍵歸一)。
|
||||
export const ENTITY_SLOTS = ['canonical', 'aliases_json', 'entity_type', 'owner', 'gloss', 'embed', 'node_id'];
|
||||
export const ENTITY_PENDING_SLOTS = [
|
||||
'raw_name', 'candidate_entity_id', 'candidate_canonical', 'similarity',
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
rejectPendingAlias,
|
||||
} from '../actions/entity-pending';
|
||||
import { listTripletEntities } from '../actions/triplet-entities';
|
||||
import { backfillGlossEntries } from '../actions/backfill-gloss-entries';
|
||||
import { makeKbdbClient } from '../lib/kbdb-client';
|
||||
|
||||
const entityRoutes = new OpenAPIHono<{ Bindings: Bindings }>();
|
||||
@@ -64,4 +65,13 @@ entityRoutes.post('/pending/:id/reject', async (c) => {
|
||||
return c.json({ success: true, action: 'rejected', newEntity });
|
||||
});
|
||||
|
||||
// POST /backfill-gloss-entries — 存量回填:對既有 entity records 補 embeddable base entry(metadata.embed=true)。
|
||||
// route 只驗參數 + 呼叫 action(樂高法:無業務邏輯)。冪等,可重複呼叫。
|
||||
entityRoutes.post('/backfill-gloss-entries', async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as { owner_id?: string };
|
||||
const owner_id = c.req.query('owner_id') || body?.owner_id || undefined;
|
||||
const result = await backfillGlossEntries(makeKbdbClient(c.env), owner_id);
|
||||
return c.json({ success: true, ...result });
|
||||
});
|
||||
|
||||
export { entityRoutes };
|
||||
|
||||
@@ -6,6 +6,11 @@ export type Bindings = {
|
||||
KBDB_BASE_URL?: string; // 基本盤 arcrun/kbdb API 網址(leo: 可設定,先留空)
|
||||
KBDB_INGEST_URL?: string; // ingest 服務網址(refresh 代轉對象;T4 就緒前留空)
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
// Service Binding(wrangler.toml [[services]])→ 直連基本盤 worker,繞開
|
||||
// CF error 1042(Worker 不能公開 fetch 另一個 *.workers.dev Worker)。
|
||||
// 2026-07-03 補跑實測發現:self-hosted 帳號常無自訂域名,KBDB_BASE_URL 單靠公開
|
||||
// fetch 在 workers.dev 對 workers.dev 場景會被 CF 擋,故新增此綁定作為正規解法。
|
||||
KBDB_BASE_SVC?: Fetcher;
|
||||
ENVIRONMENT: string;
|
||||
API_KEY?: string;
|
||||
};
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
原因: 基本盤 = D1 only(免費、無信用卡);embed 是可選加購層。插件混進來會破壞分層。
|
||||
日期: 2026-06-14
|
||||
|
||||
⚠️ MISTAKE: 補對齊/功能 PR 混進 template 基建遷移 → 撞已 merge 的遷移、害衝突
|
||||
症狀: PR#3(receiver Zod 補對齊)從 PR#2 merge【前】的分支切 → 帶了一整批 template 1.9.x 遷移檔(.claude/→system-dev/,40 個)。PR#2 已把那批搬進 main → PR#3 重複撞 = CONFLICTING/DIRTY,且真正的補對齊改動被淹沒。
|
||||
正確做法: 功能/補對齊 PR 只放該功能的改動;template/基建遷移單獨一筆 PR。撞衝突時別在舊分支硬解一堆遷移衝突 → 從最新 origin/main 重切乾淨分支、只 cherry-pick 該功能的 code commit、force-with-lease 覆蓋 PR 分支(PR 自動更新、不用關掉重開)。切分支前先確認 base 是不是落後於已 merge 的東西。
|
||||
原因: 分支從「即將被 merge 的另一支」之前切,會把對方的改動也一起帶上;對方 merge 後兩份就撞。核心改動本身不衝突,衝突全來自混進來的重複遷移。
|
||||
日期: 2026-06-26
|
||||
|
||||
---
|
||||
|
||||
格式:
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
# 當前狀態
|
||||
|
||||
> 更新時間:2026-06-14
|
||||
> 更新時間:2026-06-26
|
||||
> 每次 session 結束必須更新此檔(用 /wiki-update)。
|
||||
|
||||
---
|
||||
|
||||
## 最新(2026-06-26:issue #1 補對齊 — receiver Zod 追上 contract,PR #3 已 merge)
|
||||
|
||||
[PR #3](https://github.com/uncle6me-web/kbdb-graph-plugin/pull/3) 已 merge 進 main(commit `13db97b`)。對應 [issue #1](https://github.com/uncle6me-web/kbdb-graph-plugin/issues/1) 總管補對齊 comment。
|
||||
|
||||
**起因(契約漂移)**:T3 的 strict Zod 鏡射【當時】contract;contract 之後升格(ingest#1 向量化規範)加打標欄位 → ingest 照新 contract 送會被 `.strict()` 擋 422。總管裁定方向 A:graph 追上 contract(contract 是凍結單一真相源,實作追它)。
|
||||
|
||||
完成:
|
||||
1. ✅ `contracts/ingest-candidate.json` 副本同步到頂層單一真相源(`InkStoneCo/system-dev/docs/3-specs/mira-dissolve/`)。
|
||||
2. ✅ Zod 加契約合法新欄位(**保留 `.strict()`**):`NodeSchema`+`id?`/`aliases?`/`embed?`;`EdgeSchema`+`predicate_embed?`。
|
||||
3. ✅ 落地(向量化分工:ingest 打標、base 讀標執行、**graph 不算向量**):`predicate_embed` 透傳進 triplet slot;node 打標(`embed`/`gloss`/`aliases`)存進 entity slot;`id` 作 node 去重鍵(同卡多邊只一筆)。`persistNodes` 拆獨立 action(`src/actions/node-persist.ts`)。
|
||||
4. ✅ 測試 +4:帶向量化欄位【通過】、`bridge_score`/`clusters` 仍【422】、同 id 去重。
|
||||
|
||||
**新增 plugin slot**(非改表、非改 contract):triplet `predicate_embed`;entity `embed`/`node_id`。
|
||||
驗證:`vitest run` **23 passed**;零 SQL / 無 D1·Vectorize·AI;dry-run 乾淨;action ≤100 行。
|
||||
|
||||
**過程教訓(已記 mistakes)**:PR#3 初版從 PR#2 merge 前切 → 混進 PR#2 已做的 template 1.9.x 遷移 40 檔 → 撞 main 衝突。解法=從最新 main 重切、只 cherry-pick 補對齊那筆 code commit、瘦身後 force-push。**補對齊/功能 PR 別混 template 基建遷移。**
|
||||
|
||||
---
|
||||
|
||||
## 前一筆(2026-06-26:issue #1 T3 — ingest 寫入端 + graph 端 API,PR #2 已 merge)
|
||||
|
||||
[PR #2](https://github.com/uncle6me-web/kbdb-graph-plugin/pull/2) 已 merge(commit `7a29dee`,squash)。
|
||||
|
||||
完成:
|
||||
1. ✅ **wiki 合併**:舊 `.claude/wiki/` → `system-dev/wiki/`(導入 system-dev-template)。
|
||||
2. ✅ **ingest-contract SDD**(`docs/3-specs/ingest-contract/`)+ 搬入 `contracts/ingest-candidate.json`(T3.1/3.8)。
|
||||
3. ✅ **寫入端 + 取代**(T3.2–3.5):`POST /triplets/ingest`、ensureTemplate slot-diff 補丁、`updateRecord`、idempotency、**先 append 後 deprecate**、active-only 查詢。
|
||||
4. ✅ **get_source + refresh**(T3.6/3.7):`GET /graph/source/:name`、`POST /graph/refresh`(純被動代轉,未設 `KBDB_INGEST_URL` 時誠實回 `forwarded:false`)、keyword 收斂(3.6d)。
|
||||
|
||||
驗證:`vitest run` 19 passed(mock);zero SQL / 無 D1·Vectorize·AI;dry-run 乾淨;action ≤100 行。
|
||||
|
||||
> 註:base `PATCH /records/:id` 已就緒(Arcrun #6 closed),ingest deprecate 即用此。
|
||||
|
||||
---
|
||||
|
||||
## 已完成(2026-06-14:按 leo 鐵律全面改寫 + 獨立成 repo)
|
||||
|
||||
HANDOFF 5 項待辦全做完:
|
||||
@@ -21,15 +56,24 @@ HANDOFF 5 項待辦全做完:
|
||||
|
||||
design.md 原本「讀現狀(21 個直接 SQL)推翻鐵律、問要不要共用 D1」是**讀違規現狀推翻規則**的錯。已改正為 **API-as-Wall(走 API,非共用 D1,零建表/零 SQL)**,並記進 mistakes.md。
|
||||
|
||||
## 正在做 / 阻擋
|
||||
|
||||
- ✅ PR #2(T3)、PR #3(補對齊)皆已 merge 進 main。graph repo 端實作面收斂,**本 repo 無剩餘可單獨做的 task**。
|
||||
- [🔄] 剩三項皆「不在 graph 手上」,等跨 repo 接通 / 部署(見下)。
|
||||
|
||||
## 下次 session 第一件事
|
||||
|
||||
**實際部署**:等基本盤 `arcrun-kbdb` 上線/有網址後,跑 `bash scripts/install.sh` 一次到位
|
||||
(自動查 CF subdomain 拼 `KBDB_BASE_URL` → `wrangler secret put` → `wrangler deploy`)。
|
||||
現在不空跑部署(避免上線一個打不到基本盤的殼)。
|
||||
main 已含 T3 + 補對齊。本 repo 端無待辦——下次動工多半是**新交辦**或**跨 repo 接通就緒後接 MCP 薄殼**。
|
||||
若要實際部署:等基本盤 `arcrun-kbdb` 上線有網址後跑 `bash scripts/install.sh`(自動查 CF subdomain 拼 `KBDB_BASE_URL` → secret → deploy)。現不空跑(避免上線打不到基本盤的殼)。
|
||||
|
||||
## 待負責人確認 / 跨 repo 接通(全通才結 issue #1)
|
||||
|
||||
- **MCP 註冊薄殼** — 圖工具(traverse/neighbors/source/refresh)併入 arcrun `u6u-mcp-server`。等:總管協調 arcrun,**不另起 graph MCP**;待 Arcrun #7 部署驗。graph 端 HTTP API 已備好。
|
||||
- **refresh 端到端** — 等:ingest repo(T4)部署 + 設 `KBDB_INGEST_URL`;未設時誠實回 `forwarded:false`。
|
||||
- **semantic normalize** — 仍 exact-only,留接口;等:base embed(Arcrun #7,code done 待部署)。**補對齊已把向量化打標(embed/predicate_embed/gloss/aliases)落地進 slot 供 base 讀**,base 模組就緒即可接。
|
||||
|
||||
## 已知缺口([→arcrun],待基本盤補)
|
||||
|
||||
- base 無 `PUT /records/:id` → entity addAlias 用「重建 record」覆寫。
|
||||
- base 無 `DELETE /records/:id` → triplet/entity update/delete、pending confirm/reject 為 soft(不硬刪)。
|
||||
- 語意搜尋 / embedding 屬基本盤 optional embed 模組,插件只做 keyword/exact。
|
||||
- arcrun 端 MCP/CLI 的 KBDB 薄殼仍待補(見 arcrun HANDOFF §2);插件目前直打基本盤 HTTP API。
|
||||
- base `PATCH /records/:id` ✅ 已就緒(Arcrun #6 closed);但 base 仍無 `DELETE /records/:id` → triplet/entity delete、pending confirm/reject 為 soft(不硬刪)。
|
||||
- 語意搜尋 / embedding 屬基本盤 optional embed 模組,插件只做 keyword/exact(graph 不算向量,鐵律)。
|
||||
- arcrun 端 MCP/CLI 的 KBDB 薄殼仍待補;插件目前直打基本盤 HTTP API。
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// node gloss → embeddable base entry:走 mock KbdbClient(API-as-Wall),零 SQL、不打網路。
|
||||
// 覆蓋:前向 persistNodes 多落 embeddable entry / embed=false 不落 / 空 gloss 不落 / 冪等不重複 /
|
||||
// gloss 變則更新 / backfill 對既有 entity records 補 entry(含冪等、embed=false 略過)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { persistNodes } from '../src/actions/node-persist';
|
||||
import { backfillGlossEntries } from '../src/actions/backfill-gloss-entries';
|
||||
import { NODE_GLOSS_ENTRY_TYPE } from '../src/actions/gloss-entry';
|
||||
import { TPL_ENTITY } from '../src/lib/templates';
|
||||
import { mockClient } from './mock-client';
|
||||
|
||||
const glossEntries = (c: ReturnType<typeof mockClient>) =>
|
||||
c.listEntries({ entry_type: NODE_GLOSS_ENTRY_TYPE });
|
||||
|
||||
describe('persistNodes — 前向:每個 node 另落一筆 embeddable base entry', () => {
|
||||
it('有 gloss 的 node → 落 entry:content=名+gloss、metadata.embed=true、帶 source/node_id', async () => {
|
||||
const c = mockClient();
|
||||
await persistNodes(
|
||||
c,
|
||||
[{ name: '系統動力學', id: 'sd.md', gloss: '用因果圖表解複雜系統的方法論' }],
|
||||
'leo21c',
|
||||
'github:uncle6me-web/wiki@sd.md',
|
||||
);
|
||||
|
||||
const entries = await glossEntries(c);
|
||||
expect(entries.length).toBe(1);
|
||||
const e = entries[0];
|
||||
expect(e.content).toBe('系統動力學:用因果圖表解複雜系統的方法論');
|
||||
expect(e.page_name).toBe('gloss:sd.md');
|
||||
expect(e.owner_id).toBe('leo21c');
|
||||
const meta = JSON.parse(e.metadata_json!);
|
||||
expect(meta.embed).toBe(true); // 關鍵:base embed 模組讀這個
|
||||
expect(meta.source).toBe('github:uncle6me-web/wiki@sd.md');
|
||||
expect(meta.node_id).toBe('sd.md');
|
||||
expect(meta.canonical).toBe('系統動力學');
|
||||
|
||||
// entity record 仍照舊存在(兩者並存:record 給圖、entry 給 embed)。
|
||||
const recs = await c.listRecordsByTemplate(TPL_ENTITY);
|
||||
expect(recs.find((r) => r.values.canonical === '系統動力學')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('embed=false 的 node → 不落 embeddable entry', async () => {
|
||||
const c = mockClient();
|
||||
await persistNodes(c, [{ name: '黃仁勳', id: '黃仁勳', gloss: 'NVIDIA 創辦人', embed: false }]);
|
||||
expect((await glossEntries(c)).length).toBe(0);
|
||||
});
|
||||
|
||||
it('空 gloss 的 node → 不落 embeddable entry(不造無意義空殼)', async () => {
|
||||
const c = mockClient();
|
||||
await persistNodes(c, [{ name: '某卡', id: 'card.md' }]);
|
||||
expect((await glossEntries(c)).length).toBe(0);
|
||||
});
|
||||
|
||||
it('冪等:同一 node 重複 ingest → 只一筆 embeddable entry', async () => {
|
||||
const c = mockClient();
|
||||
const nodes = [{ name: '長壽', id: 'longevity', gloss: '活得更久本身具有價值' }];
|
||||
await persistNodes(c, nodes, 'leo21c', 'u1');
|
||||
await persistNodes(c, nodes, 'leo21c', 'u1'); // 再送一次
|
||||
expect((await glossEntries(c)).length).toBe(1);
|
||||
});
|
||||
|
||||
it('gloss 變更 → 更新既有 entry 內容(仍只一筆)', async () => {
|
||||
const c = mockClient();
|
||||
await persistNodes(c, [{ name: '長壽', id: 'longevity', gloss: '舊描述' }], 'leo21c');
|
||||
await persistNodes(c, [{ name: '長壽', id: 'longevity', gloss: '活得更久本身具有價值' }], 'leo21c');
|
||||
const entries = await glossEntries(c);
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0].content).toBe('長壽:活得更久本身具有價值');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backfillGlossEntries — 存量:對既有 entity records 補 embeddable entry', () => {
|
||||
// 直接以 entity record 造存量(模擬既有 16 筆),不經前向路徑。
|
||||
async function seedEntity(
|
||||
c: ReturnType<typeof mockClient>,
|
||||
v: { canonical: string; node_id?: string; gloss?: string; embed?: string },
|
||||
) {
|
||||
await c.createRecord(TPL_ENTITY, {
|
||||
canonical: v.canonical,
|
||||
node_id: v.node_id ?? '',
|
||||
aliases_json: '[]',
|
||||
entity_type: '',
|
||||
gloss: v.gloss ?? '',
|
||||
embed: v.embed ?? 'true',
|
||||
owner: '',
|
||||
});
|
||||
}
|
||||
|
||||
it('有 gloss 的既有 record → 落 entry;embed=false / 空 gloss → 略過', async () => {
|
||||
const c = mockClient();
|
||||
await seedEntity(c, { canonical: '長壽', node_id: 'longevity', gloss: '活得更久本身具有價值' });
|
||||
await seedEntity(c, { canonical: '系統動力學', node_id: 'sd', gloss: '用因果圖表解複雜系統的方法論' });
|
||||
await seedEntity(c, { canonical: '無標', node_id: 'x', gloss: '不該嵌', embed: 'false' });
|
||||
await seedEntity(c, { canonical: '空描述', node_id: 'y' }); // 空 gloss
|
||||
|
||||
const res = await backfillGlossEntries(c);
|
||||
expect(res.scanned).toBe(4);
|
||||
expect(res.created).toBe(2);
|
||||
expect(res.skipped).toBe(2); // embed=false + 空 gloss
|
||||
|
||||
const entries = await glossEntries(c);
|
||||
expect(entries.length).toBe(2);
|
||||
expect(entries.every((e) => JSON.parse(e.metadata_json!).embed === true)).toBe(true);
|
||||
expect(entries.map((e) => e.content).sort()).toEqual(
|
||||
['系統動力學:用因果圖表解複雜系統的方法論', '長壽:活得更久本身具有價值'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('冪等:連跑兩次 backfill → 不造重複(第二次全 unchanged)', async () => {
|
||||
const c = mockClient();
|
||||
await seedEntity(c, { canonical: '長壽', node_id: 'longevity', gloss: '活得更久本身具有價值' });
|
||||
|
||||
const first = await backfillGlossEntries(c);
|
||||
expect(first.created).toBe(1);
|
||||
|
||||
const second = await backfillGlossEntries(c);
|
||||
expect(second.created).toBe(0);
|
||||
expect(second.unchanged).toBe(1);
|
||||
expect((await glossEntries(c)).length).toBe(1);
|
||||
});
|
||||
});
|
||||
+16
-2
@@ -16,7 +16,16 @@ export class MockKbdbClient {
|
||||
|
||||
async createEntry(input: any): Promise<BaseEntry> {
|
||||
const id = this.id('entry');
|
||||
const entry: BaseEntry = { id, content: input.content ?? null, entry_type: input.entry_type, owner_id: input.owner_id ?? null };
|
||||
// 對齊 base entry-crud:保留 page_name(idempotency key)、metadata_json(embed 打標)、parent_id。
|
||||
const entry: BaseEntry = {
|
||||
id,
|
||||
content: input.content ?? null,
|
||||
entry_type: input.entry_type,
|
||||
owner_id: input.owner_id ?? null,
|
||||
parent_id: input.parent_id ?? null,
|
||||
page_name: input.page_name ?? null,
|
||||
metadata_json: input.metadata_json ?? null,
|
||||
};
|
||||
this.entries.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
@@ -26,8 +35,13 @@ export class MockKbdbClient {
|
||||
}
|
||||
|
||||
async listEntries(filters: any = {}): Promise<BaseEntry[]> {
|
||||
// 對齊 base entry-crud listEntries 的 exact-match filters(含 page_name idempotency key、parent_id)。
|
||||
return [...this.entries.values()].filter(
|
||||
(e) => (!filters.entry_type || e.entry_type === filters.entry_type) && (!filters.owner_id || e.owner_id === filters.owner_id),
|
||||
(e) =>
|
||||
(!filters.entry_type || e.entry_type === filters.entry_type) &&
|
||||
(!filters.owner_id || e.owner_id === filters.owner_id) &&
|
||||
(!filters.parent_id || e.parent_id === filters.parent_id) &&
|
||||
(!filters.page_name || e.page_name === filters.page_name),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,78 @@ describe('ingestEnvelope — 污染 envelope 422(契約 strict)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 0 });
|
||||
|
||||
const { triplets } = await queryTriplets(c, {});
|
||||
expect(triplets.length).toBe(1);
|
||||
|
||||
// node 打標落地成 entity record(gloss/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);
|
||||
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();
|
||||
|
||||
+20
-6
@@ -10,15 +10,29 @@ workers_dev = true
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "development"
|
||||
# 基本盤 arcrun/kbdb API 網址(leo 2026-06-14:做成可設定,先留空)。
|
||||
# 部署前用 `wrangler secret put` 或在此填入,例如 https://arcrun-kbdb.<acct>.workers.dev
|
||||
KBDB_BASE_URL = ""
|
||||
# 基本盤 arcrun/kbdb API 網址:務必只透過 `wrangler secret put KBDB_BASE_URL` 設定。
|
||||
# 2026-07-03 補跑實測發現的坑:若這裡也宣告 KBDB_BASE_URL(哪怕留空字串),
|
||||
# `wrangler deploy` 會用這個 [vars] 值覆蓋掉已存在的 remote secret(vars 優先權 > secret),
|
||||
# 導致部署後 KBDB_BASE_URL 被打回空字串、插件連不到基本盤。故此鍵不可出現在 [vars]。
|
||||
|
||||
[alias]
|
||||
"zod/v3" = "zod"
|
||||
"zod/v4" = "zod"
|
||||
"zod/v4-mini" = "zod"
|
||||
|
||||
[[routes]]
|
||||
pattern = "kbdb-graph.finally.click"
|
||||
custom_domain = true
|
||||
# 2026-07-03 補跑實測發現的坑:workers.dev 是共享 zone,Worker 直接 fetch()
|
||||
# 另一個 *.workers.dev Worker 會被 CF 擋(error code 1042,loop-prevention)。
|
||||
# self-hosted 帳號通常沒自訂域名可繞,正規解法 = Service Binding:CF 內部直連,
|
||||
# 不經公開網路、不受 1042 影響。KBDB_BASE_URL secret 仍保留(可讀性/未來自訂域名時 fallback),
|
||||
# 但實際讀寫走這個 binding(見 src/lib/kbdb-client.ts / makeKbdbClient)。
|
||||
[[services]]
|
||||
binding = "KBDB_BASE_SVC"
|
||||
service = "arcrun-kbdb"
|
||||
|
||||
# 2026-07-03 補跑實測:custom_domain "kbdb-graph.finally.click" 這個 zone
|
||||
# 不在 leo21c 帳號下(CF API GET /zones?name=finally.click 回空),
|
||||
# self-hosted 到 leo21c 時此路由會部署失敗(但不影響 workers.dev route 的部署本身)。
|
||||
# 註解掉以让 self-hosted 用戶乾淨部署到預設 workers.dev;有自訂域名需求者自行改回。
|
||||
# [[routes]]
|
||||
# pattern = "kbdb-graph.finally.click"
|
||||
# custom_domain = true
|
||||
|
||||
Reference in New Issue
Block a user