9 Commits

Author SHA1 Message Date
Claude 8ec6948918 fix(graph): backfill 讀 entity 不帶 owner 過濾(存量 owner=None,濾了讀0);owner 只套建立的 gloss entry 2026-07-05 11:26:16 +00:00
Claude 65075b4615 feat(graph): 收斂 gloss-bridge + owner-threading 為單一可部署分支
合併 arcrun-7-gloss-bridge(A1 gloss 橋)與 owner-threading(owner_id 寫入鏈必經):
- node-persist / triplet-ingest 衝突解為「兩者都要」:persistNodes 既寫 entity
  record(帶真 owner)又 upsert gloss entry(帶真 owner、帶 source);ingestEnvelope
  既帶 source.uri 又 thread 必填 owner 到底。
- gloss 路徑補 owner 必填(配合 owner-mandatory D28):
  * upsertGlossEntry(..., owner_id: string) owner 改必填
  * backfillGlossEntries(client, owner_id: string) owner 改必填
  * POST /backfill-gloss-entries 缺 owner_id → 400(存量 record owner=None,
    owner 由 caller 明確指定,不從 record 帶)
- 合併兩支測試:gloss 測試補 owner 參數以配合 base owner-required mock。

tsc:除 index.ts:57 swaggerUI 既存 baseline 外 0 error。
vitest:34 passed(gloss 7 + owner/graph 27)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ck8bjuBTLFkiFxqLGHMpqG
2026-07-05 11:20:47 +00:00
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
Claude 4cc7057cea feat(graph): node gloss→base embeddable entry 橋——Arcrun#7 A1
修「打標≠讀標」:node gloss+embed 標在 entity record、但 base embed 只掃 entries.metadata.embed
→ persistNodes 另落一筆 content=canonical:gloss、metadata.embed=true 的 base entry(走 API/零SQL)。
- gloss-entry.ts: upsertGlossEntry 冪等(確定性 page_name + list-then-write)
- backfill-gloss-entries.ts + route: 對既有 entity record 補 gloss entry
- triplet-ingest 帶 source.uri 進 metadata;kbdb-client 暴露 metadata_json(base 早支援)
- vitest 30/30(+7);[→arcrun] base 缺 upsert entry 端點,暫以 list-then-write 冪等
2026-07-05 09:16:26 +00:00
Claude b53c5c94a9 chore(deps): 同步 pnpm-lock.yaml 與 package.json(清掉早已移除套件的殘留鎖檔項)
補跑第一步 clone 後 fresh install 就撞坑:pnpm-lock.yaml 裡鎖著
@modelcontextprotocol/sdk、unpdf、@cloudflare/vitest-pool-workers、fast-check
四個套件,但 package.json 早就沒宣告它們了(歷史 trim 沒同步鎖檔)。
另外先誤用 `npm install` 疊加在既有 pnpm 目錄結構上,產生指向
node_modules/.pnpm/... 的斷頭 symlink,導致 wrangler deploy esbuild
"Could not resolve hono" 系列錯誤 — 清掉重跑 `pnpm install` 後才正常。
記錄給 progress-guard:clone 後第一步務必用 pnpm install,不要用 npm。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 08:58:03 +00:00
Claude 454ea8d240 fix(kbdb-client): ensureTemplate 誤判「不存在」導致重複建 template → base 500
補跑實測(POST /triplets/ingest 端到端跑不過)發現的真契約漂移,非單純部署坑:
- ensureTemplate() 假設 GET /templates/:name 回傳 {id, slots} 平鋪陣列,
  但 base(arcrun-kbdb)實際回 {success, template:{id, slots_json:"[...]"}}
  (多包一層 + slots 是 JSON 字串)。existing.id 永遠 undefined → 一律走「不存在」
  分支重複 POST 同名 template → base 對重名衝突沒有優雅的 409,直接 500,
  ingest 端到端全斷。
- 改為解 existing.template + JSON.parse(slots_json),修好後 POST /triplets/ingest
  → GET /triplets 端到端打通(見部署驗證 curl 記錄)。

同時讓 KbdbClient 支援可選 fetcher(service binding 的 Fetcher),
搭配上一個 commit 的 KBDB_BASE_SVC binding 繞開 workers.dev 互連封鎖;
沒有 binding 時(本地 dev/mock)仍 fallback 回全域 fetch,不影響既有單元測試。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 08:57:53 +00:00
Claude c7d8a74bee fix(deploy): leo21c self-host 撞牆修復 — secret 被 vars 覆蓋 + 死 zone route + workers.dev 互連被擋
T-kb-skeleton①部署到 leo21c 補跑實測發現三坑:
1. [vars] 宣告 KBDB_BASE_URL="",wrangler deploy 會用它覆蓋掉已存在的 remote
   secret(vars 優先權 > secret),導致部署後 KBDB_BASE_URL 被打回空字串。移除該鍵,
   一律只透過 `wrangler secret put` 設定。
2. [[routes]] custom_domain "kbdb-graph.finally.click" 的 zone 不在 leo21c 帳號下
   (CF API GET /zones?name=finally.click 回空),會讓 deploy 該路由失敗。註解掉,
   self-hosted 預設乾淨部署到 workers.dev。
3. 最關鍵:Worker 直接 fetch() 另一個 *.workers.dev Worker 會被 CF 擋
   (error code 1042,workers.dev 共享 zone 的 loop-prevention)。self-hosted 帳號通常
   沒自訂域名可繞,正規解法是 Service Binding(CF 內部直連,不經公開網路)。
   新增 [[services]] binding=KBDB_BASE_SVC -> arcrun-kbdb。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 08:57:43 +00:00
Leo 01f131a7a2 docs(wiki): /wiki-update — 補 T3(PR#2) + 補對齊(PR#3) 歷史 + 1 mistake
main 的 status.md 之前停在 2026-06-14(PR#2 squash 只搬檔未帶 wiki 內容更新)。
補齊三段歷史 + 更新現狀(兩 PR 已 merge、本 repo 無剩餘 task、三項跨 repo 待接)。
mistakes 記:補對齊/功能 PR 別混 template 基建遷移(PR#3 撞衝突的教訓)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:35:34 +08:00
uncle6 13db97bb54 fix(ingest): receiver Zod 追上 contract — 補向量化打標欄位 (issue #1 補對齊) (#3)
契約漂移修補: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>
2026-06-26 20:28:13 +08:00
24 changed files with 541 additions and 1765 deletions
-1686
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
// 存量回填:對「已存在的 entity records」補上對應的 embeddable base entry。
// 讀 base 既有 entity recordsGET /records/by-template/entity)→ 為每筆有 gloss 的落一筆 embeddable entry
// (同 persistNodes 前向路徑的格式與冪等)。讓既有知識立刻可被 base embed backfill 嵌到。
// 鐵律:走 base APIAPI-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, // 必經:存量 entity record 本身 owner=Noneowner 由 caller 明確指定(配合 owner-mandatory D28
): Promise<BackfillGlossResult> {
await ensurePluginTemplates(client);
// 讀「全部」存量 entity record(不帶 owner 過濾)——存量 entity 本身 owner=None,拿 owner 去濾來源會讀到 0。
// owner 只套在下面建立的 gloss entry(caller 明確指定),不套來源讀取。
const records = await client.listRecordsByTemplate(TPL_ENTITY);
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;
}
+8 -6
View File
@@ -13,12 +13,12 @@ const norm = (s: string): string => s.toLowerCase().trim();
// ─── Entity ──────────────────────────────────────────────────────────────────
/** 建立 Entitycanonical 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);
const id = await client.createRecord(
TPL_ENTITY,
{ canonical, aliases_json: '[]', entity_type: '', owner: owner ?? '' },
owner,
{ canonical, aliases_json: '[]', entity_type: '', owner }, // 真 owner 落 slot(不再 ?? ''
owner, // 必經:createRecord 內 requireOwner 守衛
);
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)。
* [→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);
if (!rec) throw new Error(`Entity ${entityId} not found`);
const ent = recordToEntity(rec);
if (ent.aliases.includes(alias)) return;
const aliases = [...ent.aliases, alias];
await ensurePluginTemplates(client);
// 重建時沿用原 record 既有 owner;原 record 無主(舊資料 owner=None)才退回 caller 指定的 owner。
const effectiveOwner = (rec.values.owner || '').trim() || owner;
await client.createRecord(
TPL_ENTITY,
{
canonical: ent.canonical,
aliases_json: JSON.stringify(aliases),
entity_type: rec.values.entity_type ?? '',
owner: rec.values.owner ?? owner ?? '',
owner: effectiveOwner,
},
owner,
effectiveOwner, // 必經:createRecord 內 requireOwner 守衛
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ import type { KbdbClient } from '../lib/kbdb-client';
export async function normalizeEntity(
client: KbdbClient,
rawName: string,
owner?: string,
owner: string, // 必經:未命中會建新 entity,必帶 owner
): Promise<string> {
try {
const exact = await findEntityByName(client, rawName, owner);
+4 -4
View File
@@ -16,7 +16,7 @@ export async function createPendingAlias(
candidateEntityId: string,
candidateCanonical: string,
similarity: number,
owner?: string,
owner: string, // 必經:pending record 也不可無主
): Promise<PendingAlias> {
await ensurePluginTemplates(client);
const id = await client.createRecord(
@@ -27,7 +27,7 @@ export async function createPendingAlias(
candidate_canonical: candidateCanonical,
similarity: String(similarity),
},
owner,
owner, // 必經:createRecord 內 requireOwner 守衛
);
return {
id,
@@ -56,14 +56,14 @@ export async function getPendingAliases(client: KbdbClient, limit = 100, owner?:
}
/** 確認 → 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);
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);
}
/** 拒絕 → 以 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);
if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`);
return createEntity(client, rec.values.raw_name, owner);
+66
View File
@@ -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 APIAPI-as-Wall)、零 SQL、不綁 Vectorizeembedding 是 base 職責,graph 只落標)。
//
// 為何要這檔:node 打標存進 entity **record**node-persist),但 base embed 只掃 **entries**.metadata_json
// .$.embed===true → 標在 record、讀在 entry 對不上,語意查不到圖內容。這裡補「同一 gloss 也落成 entry」。
//
// 冪等(base 無 upsert entry 端點):以 node 去重鍵派生確定性 page_namebase 文件化的 idempotency key),
// 先 listEntries({entry_type,page_name}) 查存 → 無則 create、內容變則 patch、同則 no-op。
// [→arcrun] 若 base 日後補「POST /entries upsertpage_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 去重鍵(同 persistNodesid 優先、無則 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 = `canonicalgloss`(名+描述一起 embed,利於語意召回)。
* - metadata_json.embed=truebase 讀此旗標)、source、node_id、canonical(回連原 node)。
*/
export async function upsertGlossEntry(
client: KbdbClient,
node: GlossEntryInput,
owner_id: string, // 必經:owner 從 persistNodes / backfill caller 帶真 owner,不得為 None(配合 owner-mandatory D28
): 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';
}
+12 -3
View File
@@ -4,6 +4,7 @@
import type { KbdbClient } from '../lib/kbdb-client';
import { TPL_ENTITY, ensurePluginTemplates } from '../lib/templates';
import { upsertGlossEntry } from './gloss-entry';
export type IngestNode = {
name: string;
@@ -22,7 +23,8 @@ export type IngestNode = {
export async function persistNodes(
client: KbdbClient,
nodes: IngestNode[],
owner_id?: string,
owner_id: string, // 必經:owner 一路從 ingest envelope / route 帶到底,不得掉成空字串
source?: string, // envelope source.uri,帶進 gloss entry 的 metadata.source(供 base backfill 依 source 過濾)
): Promise<void> {
if (!nodes || nodes.length === 0) return;
await ensurePluginTemplates(client);
@@ -42,9 +44,16 @@ export async function persistNodes(
gloss: n.gloss ?? '',
// contract 預設 true;只在明確 false 時存標(base 看 'false' 跳過 embed)。
embed: n.embed === false ? 'false' : 'true',
owner: owner_id ?? '',
owner: owner_id, // 真 owner 落 slot(不再 ?? ''
},
owner_id,
owner_id, // createRecord 內 requireOwner 守衛:缺→throw
);
// 另落一筆 embeddable base entrymetadata.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);
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export type CreateTripletData = {
object: string;
source_block_id?: string;
confidence?: number;
owner_id?: string;
owner_id: string; // 必經:三元組寫入必帶 ownercreateRecord 內 requireOwner 守衛)
clusters?: string[];
bridge_score?: number;
subject_entity_type?: string;
+2 -2
View File
@@ -55,7 +55,7 @@ export async function extractTripletsViaLLM(ai: Ai, chunks: string[]): Promise<L
export async function writeTripletToDb(
client: KbdbClient,
t: { subject: string; predicate: string; object: string; confidence?: number },
owner: string | null,
owner: string, // 必經:萃取寫入必帶 owner(不再收 null → 不會 owner_id: undefined
): Promise<boolean> {
// 查重:以 S-P-O 三欄精確比對(queryTriplets 取 template record 後在插件層 filter
const { count } = await queryTriplets(client, {
@@ -71,7 +71,7 @@ export async function writeTripletToDb(
predicate: t.predicate,
object: t.object,
confidence: t.confidence ?? 0.8,
owner_id: owner ?? undefined,
owner_id: owner,
});
return true;
}
+3 -2
View File
@@ -53,7 +53,7 @@ export type IngestResult = { skipped: boolean; ingested: number; deprecated: num
export async function ingestEnvelope(
client: KbdbClient,
env: IngestEnvelope,
owner_id?: string,
owner_id: string, // 必經:owner 從 route 帶入,一路 thread 進 triplet + node 寫入,不得掉成空
): Promise<IngestResult> {
await ensurePluginTemplates(client);
@@ -84,7 +84,8 @@ export async function ingestEnvelope(
// 1b) 落地 node 層打標(embed / gloss / aliases),供 base embed 模組讀標執行 embedding。
// graph 自己不算向量(鐵律一致)。id 作去重鍵:同一卡(同 id/檔名)只存一筆 entity,不以邊數重複。
await persistNodes(client, env.nodes ?? [], owner_id);
// 並為每個 node 另落一筆 embeddable base entrysource.uri 帶進 metadata.source)。
await persistNodes(client, env.nodes ?? [], owner_id, env.source.uri);
// 2) 後翻舊批 status=deprecated(指向本批 source_uriappend 在前 → 無空窗)。
for (const old of priorActive) {
+62 -12
View File
@@ -13,6 +13,9 @@ export type BaseEntry = {
owner_id: string | null;
parent_id?: string | null;
page_name?: string | null;
// base entries 的通用 metadataTEXTJSON 字串)。base embed 模組讀 metadata_json.$.embed / $.source
// 決定要不要嵌、嵌進哪個 source 分組(見 arcrun/kbdb src/embed.ts)。插件只落標、不算向量。
metadata_json?: string | null;
created_at?: number;
updated_at?: number;
};
@@ -26,16 +29,43 @@ export type BaseRecord = {
export type CreateEntryInput = {
content: string | null;
entry_type: string;
owner_id?: string;
// owner_id 必經(D27/D28):型別上必填、且執行期缺→throw。不再靜默送空 → 不寫出無主資料。
owner_id: string;
parent_id?: string;
// page_name = base 文件化的 idempotency keyentry-crud.tsexact-match lookup)。
page_name?: string;
// metadata_json = base 既有欄位(JSON 字串)。標 { embed:true, source, ... } 供 base embed 模組讀標執行。
metadata_json?: string;
};
/**
* owner_id 必經守衛(D27/D282026-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。 */
export class KbdbClient {
constructor(
private readonly baseUrl: string,
private readonly token?: string,
// 2026-07-03 補跑實測發現的坑:Cloudflare 會擋 Worker → 另一個 *.workers.dev
// Worker 的直連 fetcherror code 1042loop-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 +76,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),
@@ -63,7 +94,8 @@ export class KbdbClient {
// --- entries ---
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;
}
@@ -113,34 +145,46 @@ 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],
});
}
// --- 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', {
template,
values,
owner_id,
owner_id: owner,
});
return record.record_id;
}
@@ -175,7 +219,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_SVCservice 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);
}
+20 -2
View File
@@ -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 }>();
@@ -54,14 +55,31 @@ entityRoutes.openapi(listPendingRoute, async (c) => {
entityRoutes.post('/pending/:id/confirm', async (c) => {
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 });
});
entityRoutes.post('/pending/:id/reject', async (c) => {
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 });
});
// POST /backfill-gloss-entries — 存量回填:對既有 entity records 補 embeddable base entrymetadata.embed=true)。
// route 只驗參數 + 呼叫 action(樂高法:無業務邏輯)。冪等,可重複呼叫。
entityRoutes.post('/backfill-gloss-entries', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { owner_id?: string };
// owner 必經:存量 entity record 本身 owner=Noneowner 由 caller 明確指定(不從 record 帶)。缺→400 不寫無主資料。
const owner_id = c.req.query('owner_id') || body?.owner_id;
if (!owner_id) return c.json({ success: false, error: 'owner_id required' }, 400);
const result = await backfillGlossEntries(makeKbdbClient(c.env), owner_id);
return c.json({ success: true, ...result });
});
export { entityRoutes };
+8 -2
View File
@@ -77,7 +77,7 @@ const createRouteDefinition = createRoute({
subject: z.string().min(1),
predicate: 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(),
confidence: z.number().optional(),
clusters: z.array(z.string()).optional(),
@@ -103,10 +103,13 @@ const ingestRoute = createRoute({
method: 'post',
path: '/ingest',
request: {
// owner_id 走 route 參數(envelope 是 .strict() 凍結契約,不塞 owner 進去)。缺→400。
query: z.object({ owner_id: z.string().optional().describe('資料所有者(必填,缺→400') }),
body: { content: { 'application/json': { schema: IngestEnvelopeSchema } } },
},
responses: {
200: { description: 'Envelope ingested (or skipped if same content_hash)' },
400: { description: 'Missing owner_id (資料不可無主)' },
422: { description: 'Invalid envelope (forbidden field or shape mismatch)' },
},
tags: ['Triplets'],
@@ -115,8 +118,11 @@ const ingestRoute = createRoute({
tripletRoutes.openapi(
ingestRoute,
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 result = await ingestEnvelope(makeKbdbClient(c.env), env);
const result = await ingestEnvelope(makeKbdbClient(c.env), env, owner_id);
return c.json(result, 200);
},
// strict() 驗證失敗(如送禁止欄位 bridge_score)→ 422,不是預設 400。
+5
View File
@@ -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 Bindingwrangler.toml [[services]])→ 直連基本盤 worker,繞開
// CF error 1042Worker 不能公開 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;
};
+6
View File
@@ -51,6 +51,12 @@
原因: 基本盤 = D1 only(免費、無信用卡);embed 是可選加購層。插件混進來會破壞分層。
日期: 2026-06-14
⚠️ MISTAKE: 補對齊/功能 PR 混進 template 基建遷移 → 撞已 merge 的遷移、害衝突
症狀: PR#3receiver 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
---
格式:
+52 -8
View File
@@ -1,10 +1,45 @@
# 當前狀態
> 更新時間:2026-06-14
> 更新時間:2026-06-26
> 每次 session 結束必須更新此檔(用 /wiki-update)。
---
## 最新(2026-06-26issue #1 補對齊 — receiver Zod 追上 contractPR #3 已 merge
[PR #3](https://github.com/uncle6me-web/kbdb-graph-plugin/pull/3) 已 merge 進 maincommit `13db97b`)。對應 [issue #1](https://github.com/uncle6me-web/kbdb-graph-plugin/issues/1) 總管補對齊 comment。
**起因(契約漂移)**T3 的 strict Zod 鏡射【當時】contractcontract 之後升格(ingest#1 向量化規範)加打標欄位 → ingest 照新 contract 送會被 `.strict()` 擋 422。總管裁定方向 Agraph 追上 contractcontract 是凍結單一真相源,實作追它)。
完成:
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 slotnode 打標(`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·AIdry-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-26issue #1 T3 — ingest 寫入端 + graph 端 APIPR #2 已 merge
[PR #2](https://github.com/uncle6me-web/kbdb-graph-plugin/pull/2) 已 mergecommit `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.23.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 passedmock);zero SQL / 無 D1·Vectorize·AIdry-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 #2T3)、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 repoT4)部署 + 設 `KBDB_INGEST_URL`;未設時誠實回 `forwarded:false`
- **semantic normalize** — 仍 exact-only,留接口;等:base embedArcrun #7code 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/exactgraph 不算向量,鐵律)。
- arcrun 端 MCP/CLI 的 KBDB 薄殼仍待補;插件目前直打基本盤 HTTP API
+20 -8
View File
@@ -5,17 +5,29 @@ import { normalizeEntity } from '../src/actions/entity-normalize';
import { mockClient } from './mock-client';
describe('entity-crud', () => {
it('建立後可 exact 查回(大小寫不敏感)', async () => {
it('建立後可 exact 查回(大小寫不敏感)owner slot 帶真 owner', async () => {
const c = mockClient();
await createEntity(c, 'InkStone');
const found = await findEntityByName(c, 'inkstone');
const ent = await createEntity(c, 'InkStone', 'leo');
const found = await findEntityByName(c, 'inkstone', 'leo');
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 () => {
const c = mockClient();
await createEntity(c, 'A');
await createEntity(c, 'B');
await createEntity(c, 'A', 'leo');
await createEntity(c, 'B', 'leo');
const all = await listEntities(c);
expect(all.map((e) => e.canonical).sort()).toEqual(['A', 'B']);
});
@@ -24,8 +36,8 @@ describe('entity-crud', () => {
describe('normalizeEntity', () => {
it('已存在回 canonical,不存在建新回原值', async () => {
const c = mockClient();
await createEntity(c, 'InkStone');
expect(await normalizeEntity(c, 'INKSTONE')).toBe('InkStone');
expect(await normalizeEntity(c, '新公司')).toBe('新公司');
await createEntity(c, 'InkStone', 'leo');
expect(await normalizeEntity(c, 'INKSTONE', 'leo')).toBe('InkStone');
expect(await normalizeEntity(c, '新公司', 'leo')).toBe('新公司');
});
});
+120
View File
@@ -0,0 +1,120 @@
// node gloss → embeddable base entry:走 mock KbdbClientAPI-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 → 落 entrycontent=名+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 }], 'leo21c');
expect((await glossEntries(c)).length).toBe(0);
});
it('空 gloss 的 node → 不落 embeddable entry(不造無意義空殼)', async () => {
const c = mockClient();
await persistNodes(c, [{ name: '某卡', id: 'card.md' }], 'leo21c');
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: '', // slot 存量 owner=None(模擬 D28 前的無主 record);backfill 的 owner 由 caller 明確指定
}, 'leo21c'); // 記錄層 owner_idbase D28 後 createRecord 一律要 ownercaller 明指
}
it('有 gloss 的既有 record → 落 entryembed=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, 'leo21c');
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, 'leo21c');
expect(first.created).toBe(1);
const second = await backfillGlossEntries(c, 'leo21c');
expect(second.created).toBe(0);
expect(second.unchanged).toBe(1);
expect((await glossEntries(c)).length).toBe(1);
});
});
+3 -3
View File
@@ -12,7 +12,7 @@ describe('getSource — 回節點的原文來源指標', () => {
source: { uri: 'github:u/w@a.md', content_hash: 'h1', anchor: '#graph-rag' },
extractor: { model: 'm', tier: 'deep' },
triplets: [{ subject: 'GraphRAG', predicate: '是', object: 'RAG 變體' }],
});
}, 'leo');
const refs = await getSource(c, 'GraphRAG');
expect(refs.length).toBe(1);
@@ -27,12 +27,12 @@ describe('getSource — 回節點的原文來源指標', () => {
source: { uri: 'github:u/w@a.md', content_hash: 'h1', anchor: '#old' },
extractor: { model: 'm', tier: 'deep' },
triplets: [{ subject: 'X', predicate: 'r', object: 'old' }],
});
}, 'leo');
await ingestEnvelope(c, {
source: { uri: 'github:u/w@a.md', content_hash: 'h2', anchor: '#new' },
extractor: { model: 'm', tier: 'deep' },
triplets: [{ subject: 'X', predicate: 'r', object: 'new' }],
});
}, 'leo');
const refs = await getSource(c, 'X');
expect(refs.length).toBe(1);
+4 -4
View File
@@ -7,10 +7,10 @@ import { mockClient } from './mock-client';
import type { KbdbClient } from '../src/lib/kbdb-client';
async function seed(c: KbdbClient) {
// A — B — CD 孤立連 A
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B' });
await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C' });
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D' });
// A — B — CD 孤立連 A(owner 必經:所有寫入帶真 owner)
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B', owner_id: 'leo' });
await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C', owner_id: 'leo' });
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D', owner_id: 'leo' });
}
describe('graph-nodes', () => {
+20 -2
View File
@@ -15,8 +15,19 @@ export class MockKbdbClient {
}
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 entry: BaseEntry = { id, content: input.content ?? null, entry_type: input.entry_type, owner_id: input.owner_id ?? null };
// 對齊 base entry-crud:保留 page_nameidempotency key)、metadata_jsonembed 打標)、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 +37,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),
);
}
@@ -61,6 +77,8 @@ export class MockKbdbClient {
}
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');
this.records.set(id, { template, values: { ...values }, owner_id });
return id;
+14 -4
View File
@@ -6,7 +6,7 @@ import { mockClient } from './mock-client';
describe('createTriplet → records API', () => {
it('建立後可由 id 取回', async () => {
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.subject).toBe('InkStone');
@@ -14,13 +14,23 @@ describe('createTriplet → records API', () => {
expect(got?.predicate).toBe('是');
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', () => {
it('by subject 過濾', async () => {
const c = mockClient();
await createTriplet(c, { subject: 'KBDB', predicate: '使用', object: 'D1' });
await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X' });
await createTriplet(c, { subject: 'KBDB', predicate: '使用', object: 'D1', owner_id: 'leo' });
await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X', owner_id: 'leo' });
const { triplets, count } = await queryTriplets(c, { subject: 'KBDB' });
expect(count).toBe(1);
@@ -29,7 +39,7 @@ describe('queryTriplets → 插件層 filter', () => {
it('limit/offset 分頁', async () => {
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 });
expect(triplets.length).toBe(2);
});
+47 -9
View File
@@ -1,10 +1,13 @@
// 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 },
@@ -19,7 +22,7 @@ describe('ingestEnvelope — 正常 envelope', () => {
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, {});
@@ -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 帶真 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' }]));
const res = 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' }]), OWNER);
expect(res.skipped).toBe(true);
const { triplets } = await queryTriplets(c, {});
@@ -45,8 +83,8 @@ describe('ingestEnvelope — 同 hash no-op', () => {
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' }]));
const res = await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]));
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 查詢只見新批。
@@ -103,7 +141,7 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
expect(IngestEnvelopeSchema.safeParse(env).success).toBe(true);
// 落地: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 });
const { triplets } = await queryTriplets(c, {});
@@ -131,7 +169,7 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
],
triplets: [{ subject: 'Graph RAG', predicate: 'r', object: 'X' }],
};
await ingestEnvelope(c, env);
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);
});
@@ -160,8 +198,8 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
describe('ingestEnvelope — rollback(翻回 status', () => {
it('把 deprecated 翻回 active 後,active 查詢重新見到它', async () => {
const c = mockClient();
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]));
await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]));
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 });
+20 -6
View File
@@ -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 secretvars 優先權 > 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 是共享 zoneWorker 直接 fetch()
# 另一個 *.workers.dev Worker 會被 CF 擋(error code 1042loop-prevention)。
# self-hosted 帳號通常沒自訂域名可繞,正規解法 = Service BindingCF 內部直連,
# 不經公開網路、不受 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