5 Commits

Author SHA1 Message Date
Leo fbd375f7ac docs(wiki): /wiki-update — 記 issue #1 T3 session 結果 + 4 決策 + 2 mistakes
- status.md:2026-06-26 進度(PR #2、T3 A/B/C 段完成、跨 repo 待接、缺口表)
- decisions-summary.md:先 append 後 deprecate / idempotency slot / ensureTemplate
  slot-diff / refresh 純被動代轉
- mistakes.md:照字面刪 action 沒查反向依賴 / 拿錯 gate(tsc) 誤判改壞

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:22:16 +08:00
Leo 613071f41d feat(graph): get_source + refresh 端點 + keyword 收斂 (T3.6-3.7)
對應 issue #1 T3 C 段(圖工具 HTTP API 備好,MCP 註冊薄殼待 arcrun)。

- get_source (3.7): graph-source.ts + GET /graph/source/:name —
  回節點的 active triplet 來源指標(uri/anchor/block_id/content_hash),去重。
  連帶加 source_anchor slot,ingest 從 source.anchor 帶入
- refresh (3.6/3.6b): graph-refresh.ts + POST /graph/refresh —
  純被動代轉 ingest(KBDB_INGEST_URL),只人發起、無排程/webhook(fan-out 紅線)。
  未設 URL → 誠實 forwarded:false,不假綠
- 3.6d: POST /search 移除公開 keyword 模式(重複 KBDB MCP),收斂 suggest-only;
  keywordSearch helper 留作 suggest 內部建構塊
- 3 新測試(get_source uri+anchor / active-only / refresh 未就緒誠實回報)

gates: vitest 19 passed / zero SQL / 無新綁定 / dry-run bundle 乾淨
待接:MCP 註冊薄殼併 arcrun u6u-mcp-server;refresh 端到端待 ingest(T4) 部署

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:24:04 +08:00
Leo 27f7448914 feat(ingest): POST /triplets/ingest 寫入端 + deprecate-then-append (T3.2-3.5)
對應 issue #1 T3 B 段。

- templates: TRIPLET_SLOTS 加 status/superseded_by/source_uri/content_hash;
  ENTITY_SLOTS 加 gloss;recordToTriplet 映射新欄位(缺省 status=active 相容舊資料)
- kbdb-client: ensureTemplate 改 slot-diff 補丁(既有 template 走 PATCH /templates/:id
  補缺 slot,取代 early-return → 免遷移腳本);新增 updateRecord(PATCH /records/:id)
- triplet-ingest action(88 行純函式):Zod strict 鏡射 ingest-candidate 契約 →
  idempotency(uri+hash 同→no-op)→ 先 append 後 deprecate(無「全無 active」空窗)
- POST /triplets/ingest route:strict 驗證失敗 → 422(禁送 graph 領域欄位)
- queryTriplets 預設 active-only(traverse/search/neighbors 皆經此),
  includeDeprecated opt-out 供 rollback/考古
- 6 測試案全綠(vitest 16 passed);mock-client 同步 slot-diff + updateRecord

gates: zero SQL / zero migration / 無 D1·Vectorize·AI 綁定 / dry-run bundle 乾淨

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:13:49 +08:00
Leo 98b221b435 docs(sdd): 立 ingest-contract SDD + 搬入 ingest-candidate 契約 (T3.1+T3.8)
對應 issue #1(頂層 mira-dissolve T3)。

- contracts/ingest-candidate.json:ingest→graph 邊界契約(自頂層搬入)
- contracts/README.md:標明候選(輸入)≠已存(triplet)
- docs/3-specs/ingest-contract/design.md + tasks.md:
  - ensureTemplate 改 slot-diff 補丁(取代 early-return,免遷移腳本)
  - 補 KbdbClient.updateRecord(base PATCH /records/:id)
  - ingest 流程:驗證(422)→idempotency(uri+hash)→先 append 後 deprecate
  - triplet template 增 source_uri+content_hash slot 承載 idempotency
  - 跨 repo 協調點(3.6 圖工具併 KBDB MCP)明列需 arcrun 配合

總管已認可四個設計決定(issue #1 comment)。鐵律:零建表/零 SQL/零 migration。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:07:12 +08:00
Leo 2dd80cd703 chore(wiki): 導入 system-dev-template + 合併 wiki 到新位置
- system-dev/ 模板包進版控(VERSION/docs/scripts/wiki 骨架)
- 逐檔合併舊 .claude/wiki/ → system-dev/wiki/:
  - status/mistakes/decisions-summary 真資料覆蓋空範本
  - INDEX 新「多角度視圖」結構 + 舊決策/導航併入(過時詞「萬物皆 Block」改 API-as-Wall)
  - principles/TAXONOMY 為新位置獨有,保留
- 刪舊 .claude/wiki/(git 識別為 rename,內容完整搬移)
- 三層機敏防護 hooks + wiki 命令更新

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:06:52 +08:00
25 changed files with 1809 additions and 456 deletions
+5 -26
View File
@@ -60,7 +60,7 @@
}, },
"nodes": { "nodes": {
"type": "array", "type": "array",
"description": "節點層附帶資訊。【向量化分工(leo 2026-06-26,ingest#1 升格成契約)】ingest 在此【打標】哪些 token 要向量化 + embed 什麼;base/KBDB embed 模組【讀標執行】實際 embedding;ingest 自己不算向量。兩類節點(實體詞條 / wikilink 卡)都進 nodes[],謂詞向量見 triplets[].predicate_vector。", "description": "節點層附帶資訊(選填)。entity_type 與 gloss 是【節點】屬性,不是【邊】屬性 → 放這裡,不放 triplets。graph 用 gloss 去 embed(每節點一句,不是裸詞)、用 entity_type 去 typing。",
"items": { "items": {
"type": "object", "type": "object",
"required": ["name"], "required": ["name"],
@@ -69,26 +69,11 @@
"name": { "name": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"description": "節點名(須對應某 triplet 的 subject/object 原字面)。實體詞條=正規名;wikilink 卡=卡標題。" "description": "節點名(須對應某 triplet 的 subjectobject 原字面)。"
},
"id": {
"type": "string",
"description": "去重鍵。wikilink 卡用【檔名】→ 一卡一 node,被多條邊指到也只 embed 一次,不以出現次數重複。實體詞條用正規名。選填(無則以 name 去重)。"
}, },
"gloss": { "gloss": {
"type": "string", "type": "string",
"description": "一句話描述。base embed 對【名 + gloss 一起】embedding(實體同義詞字面差太遠,靠描述拉近)。選填(建議 deep tier 產)。" "description": "一句話描述,供 embedding。例如 'Graph RAG — 用關係遍歷檢索、保住異見的 RAG 變體'。選填(建議 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": { "entity_type": {
"type": "string", "type": "string",
@@ -101,7 +86,7 @@
"triplets": { "triplets": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,
"description": "邊(關係)。ingest 只產原始 (s,p,o) + confidence + 謂詞向量打標。端點(s/o)以字面 match nodes[].name。", "description": "邊(關係)。ingest 只產原始 (s,p,o) + confidence。",
"items": { "items": {
"type": "object", "type": "object",
"required": ["subject", "predicate", "object"], "required": ["subject", "predicate", "object"],
@@ -110,16 +95,10 @@
"subject": { "type": "string", "minLength": 1, "description": "主詞(實體名,須與 nodes[].name 對得上若有提供)" }, "subject": { "type": "string", "minLength": 1, "description": "主詞(實體名,須與 nodes[].name 對得上若有提供)" },
"predicate": { "type": "string", "minLength": 1, "description": "謂詞(關係)" }, "predicate": { "type": "string", "minLength": 1, "description": "謂詞(關係)" },
"object": { "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 不據此過濾,只記錄。" } "confidence":{ "type": "number", "minimum": 0, "maximum": 1, "default": 1.0, "description": "萃取可信度。淺萃可附自評;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。" "$comment": "禁止欄位(graph 領域,ingest 絕不可送): id / clusters / bridge_score / created_at / updated_at / 以及 triplet 上的 subject_entity_type|object_entity_type(類型只走 nodes[])。送了即違反 ingest=純餵食器的邊界,graph 應拒收或忽略。"
} }
+1686
View File
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -13,12 +13,12 @@ const norm = (s: string): string => s.toLowerCase().trim();
// ─── Entity ────────────────────────────────────────────────────────────────── // ─── Entity ──────────────────────────────────────────────────────────────────
/** 建立 Entitycanonical name)。底層 = 一筆 entity template record。 */ /** 建立 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); await ensurePluginTemplates(client);
const id = await client.createRecord( const id = await client.createRecord(
TPL_ENTITY, TPL_ENTITY,
{ canonical, aliases_json: '[]', entity_type: '', owner }, // 真 owner 落 slot(不再 ?? '' { canonical, aliases_json: '[]', entity_type: '', owner: owner ?? '' },
owner, // 必經:createRecord 內 requireOwner 守衛 owner,
); );
return { id, canonical, aliases: [] }; return { id, canonical, aliases: [] };
} }
@@ -45,23 +45,21 @@ 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: effectiveOwner, owner: rec.values.owner ?? owner ?? '',
}, },
effectiveOwner, // 必經:createRecord 內 requireOwner 守衛 owner,
); );
} }
+1 -1
View File
@@ -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, // 必經:未命中會建新 entity,必帶 owner owner?: string,
): Promise<string> { ): Promise<string> {
try { try {
const exact = await findEntityByName(client, rawName, owner); const exact = await findEntityByName(client, rawName, owner);
+4 -4
View File
@@ -16,7 +16,7 @@ export async function createPendingAlias(
candidateEntityId: string, candidateEntityId: string,
candidateCanonical: string, candidateCanonical: string,
similarity: number, similarity: number,
owner: string, // 必經:pending record 也不可無主 owner?: string,
): 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, // 必經:createRecord 內 requireOwner 守衛 owner,
); );
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);
-50
View File
@@ -1,50 +0,0 @@
// node 層打標落地 — 把 envelope nodes[] 的向量化打標(embed/gloss/aliases)存進 entity slot。
// 向量化分工(ingest#1 升格,2026-06-26):ingest 打標、base/KBDB embed 模組讀標執行;graph 不算向量。
// 鐵律:走 base APIAPI-as-Wall)、零 SQL。
import type { KbdbClient } from '../lib/kbdb-client';
import { TPL_ENTITY, ensurePluginTemplates } from '../lib/templates';
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, // 必經:owner 一路從 ingest envelope / route 帶到底,不得掉成空字串
): 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 落 slot(不再 ?? ''
},
owner_id, // createRecord 內 requireOwner 守衛:缺→throw
);
}
}
+1 -3
View File
@@ -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; // 必經:三元組寫入必帶 ownercreateRecord 內 requireOwner 守衛) owner_id?: string;
clusters?: string[]; clusters?: string[];
bridge_score?: number; bridge_score?: number;
subject_entity_type?: string; subject_entity_type?: string;
@@ -21,7 +21,6 @@ export type CreateTripletData = {
source_uri?: string; source_uri?: string;
content_hash?: string; content_hash?: string;
source_anchor?: string; source_anchor?: string;
predicate_embed?: boolean; // 謂詞向量化打標(ingest 打標、base 讀標執行);graph 不算向量
}; };
/** 建立三元組 → POST /recordstemplate=triplet)。 */ /** 建立三元組 → POST /recordstemplate=triplet)。 */
@@ -49,7 +48,6 @@ export async function createTriplet(
if (data.source_uri) values.source_uri = data.source_uri; if (data.source_uri) values.source_uri = data.source_uri;
if (data.content_hash) values.content_hash = data.content_hash; if (data.content_hash) values.content_hash = data.content_hash;
if (data.source_anchor) values.source_anchor = data.source_anchor; 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); const id = await client.createRecord(TPL_TRIPLET, values, data.owner_id);
return { id, subject: data.subject, predicate: data.predicate, object: data.object }; return { id, subject: data.subject, predicate: data.predicate, object: data.object };
+2 -2
View File
@@ -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, // 必經:萃取寫入必帶 owner(不再收 null → 不會 owner_id: undefined owner: string | null,
): 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, owner_id: owner ?? undefined,
}); });
return true; return true;
} }
+2 -14
View File
@@ -6,17 +6,11 @@ import { z } from '@hono/zod-openapi';
import type { KbdbClient } from '../lib/kbdb-client'; import type { KbdbClient } from '../lib/kbdb-client';
import { TPL_TRIPLET, ensurePluginTemplates, recordToTriplet } from '../lib/templates'; import { TPL_TRIPLET, ensurePluginTemplates, recordToTriplet } from '../lib/templates';
import { createTriplet } from './triplet-crud'; import { createTriplet } from './triplet-crud';
import { persistNodes } from './node-persist';
// Zod 鏡射契約:strict() = additionalProperties:false → 禁送欄位 422route 把 ZodError 轉 422)。 // Zod 鏡射契約:strict() = additionalProperties:false → 禁送欄位 422route 把 ZodError 轉 422)。
// 向量化打標欄位(ingest#1 升格,2026-06-26):ingest 打標、base/KBDB embed 模組讀標執行;graph 自己不算向量。
// strict() 仍保留 → 真正的 graph 領域禁送欄位(bridge_score / clusters / 邊上 entity_type)照樣 422。
const NodeSchema = z.object({ const NodeSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
id: z.string().optional(), // 去重鍵(wikilink 卡用檔名 → 一卡一 node,多邊指到不重建)
aliases: z.array(z.string()).optional(), // 同義詞,base collapse 成同一 node
gloss: z.string().optional(), gloss: z.string().optional(),
embed: z.boolean().optional(), // 向量化打標,base 讀標執行(預設 true)
entity_type: z.enum(['person', 'event', 'product', 'market', 'org']).optional(), entity_type: z.enum(['person', 'event', 'product', 'market', 'org']).optional(),
}).strict(); }).strict();
@@ -24,7 +18,6 @@ const EdgeSchema = z.object({
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),
predicate_embed: z.boolean().optional(), // 謂詞向量化打標,base 讀標執行(預設 true)
confidence: z.number().min(0).max(1).optional(), confidence: z.number().min(0).max(1).optional(),
}).strict(); }).strict();
@@ -53,7 +46,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 從 route 帶入,一路 thread 進 triplet + node 寫入,不得掉成空 owner_id?: string,
): Promise<IngestResult> { ): Promise<IngestResult> {
await ensurePluginTemplates(client); await ensurePluginTemplates(client);
@@ -66,14 +59,13 @@ export async function ingestEnvelope(
return { skipped: true, ingested: 0, deprecated: 0 }; return { skipped: true, ingested: 0, deprecated: 0 };
} }
// 1) 先 append 新批 active(透傳 predicate_embed 打標,供 base embed 模組讀標執行) // 1) 先 append 新批 active。
for (const e of env.triplets) { for (const e of env.triplets) {
await createTriplet(client, { await createTriplet(client, {
subject: e.subject, subject: e.subject,
predicate: e.predicate, predicate: e.predicate,
object: e.object, object: e.object,
confidence: e.confidence, confidence: e.confidence,
predicate_embed: e.predicate_embed,
source_block_id: env.source.block_id, source_block_id: env.source.block_id,
source_uri: env.source.uri, source_uri: env.source.uri,
content_hash: env.source.content_hash, content_hash: env.source.content_hash,
@@ -82,10 +74,6 @@ export async function ingestEnvelope(
}); });
} }
// 1b) 落地 node 層打標(embed / gloss / aliases),供 base embed 模組讀標執行 embedding。
// graph 自己不算向量(鐵律一致)。id 作去重鍵:同一卡(同 id/檔名)只存一筆 entity,不以邊數重複。
await persistNodes(client, env.nodes ?? [], owner_id);
// 2) 後翻舊批 status=deprecated(指向本批 source_uriappend 在前 → 無空窗)。 // 2) 後翻舊批 status=deprecated(指向本批 source_uriappend 在前 → 無空窗)。
for (const old of priorActive) { for (const old of priorActive) {
await client.updateRecord(old.id, { status: 'deprecated', superseded_by: env.source.content_hash }); await client.updateRecord(old.id, { status: 'deprecated', superseded_by: env.source.content_hash });
+12 -56
View File
@@ -26,40 +26,16 @@ export type BaseRecord = {
export type CreateEntryInput = { export type CreateEntryInput = {
content: string | null; content: string | null;
entry_type: string; entry_type: string;
// owner_id 必經(D27/D28):型別上必填、且執行期缺→throw。不再靜默送空 → 不寫出無主資料。 owner_id?: string;
owner_id: string;
parent_id?: string; parent_id?: string;
page_name?: string; page_name?: string;
}; };
/**
* owner_id D27/D282026-07-05
* owner owner:'' base owner=None mira owner 使
* 16 gloss entry owner=Nonebase owner 400fail 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(
private readonly baseUrl: string, private readonly baseUrl: string,
private readonly token?: 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) { if (!baseUrl) {
throw new Error('KBDB_BASE_URL 未設定:插件需指向基本盤 API(不可直連 D1)'); throw new Error('KBDB_BASE_URL 未設定:插件需指向基本盤 API(不可直連 D1)');
@@ -70,8 +46,7 @@ export class KbdbClient {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`; if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
const doFetch = this.fetcher ? this.fetcher.fetch.bind(this.fetcher) : fetch; const res = await fetch(this.baseUrl.replace(/\/$/, '') + path, {
const res = await doFetch(this.baseUrl.replace(/\/$/, '') + path, {
method, method,
headers, headers,
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
@@ -88,8 +63,7 @@ export class KbdbClient {
// --- entries --- // --- entries ---
async createEntry(input: CreateEntryInput): Promise<BaseEntry> { async createEntry(input: CreateEntryInput): Promise<BaseEntry> {
const owner_id = requireOwner(input.owner_id, 'createEntry'); const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', input);
const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', { ...input, owner_id });
return entry; return entry;
} }
@@ -139,46 +113,34 @@ export class KbdbClient {
// --- templates= 替代建表;插件要新類型只能建 template) --- // --- templates= 替代建表;插件要新類型只能建 template) ---
async ensureTemplate(name: string, slots: string[], description?: string): Promise<void> { async ensureTemplate(name: string, slots: string[], description?: string): Promise<void> {
// 2026-07-03 補跑實測發現的坑:base 的 GET /templates/:name 回傳是 const existing = await this.req<{ id?: string; slots?: string[] } | { error: string }>(
// { 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', 'GET',
`/templates/${encodeURIComponent(name)}`, `/templates/${encodeURIComponent(name)}`,
).catch(() => null); ).catch(() => null);
const tpl = existing?.template;
// 全新 template → 建。 // 全新 template → 建。
if (!tpl || !tpl.id) { if (!existing || !(existing as any).id) {
await this.req('POST', '/templates', { name, slots, description, created_by: 'kbdb-graph' }); await this.req('POST', '/templates', { name, slots, description, created_by: 'kbdb-graph' });
return; return;
} }
// 既有 template → 補缺 slot(不 early-return;否則 seed 後新增的 slot 永遠進不來)。 // 既有 template → 補缺 slot(不 early-return;否則 seed 後新增的 slot 永遠進不來)。
// 走 base PATCH /templates/:id 增 slot;既有環境免另跑遷移腳本即收斂。 // 走 base PATCH /templates/:id 增 slot;既有環境免另跑遷移腳本即收斂。
let haveList: string[] = []; const have = new Set((existing as any).slots ?? []);
try {
haveList = JSON.parse(tpl.slots_json ?? '[]');
} catch {
haveList = [];
}
const have = new Set(haveList);
const missing = slots.filter((s) => !have.has(s)); const missing = slots.filter((s) => !have.has(s));
if (missing.length === 0) return; if (missing.length === 0) return;
await this.req('PATCH', `/templates/${encodeURIComponent(tpl.id)}`, { await this.req('PATCH', `/templates/${encodeURIComponent((existing as any).id)}`, {
slots: [...have, ...missing], slots: [...have, ...missing],
}); });
} }
// --- 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, owner_id,
}); });
return record.record_id; return record.record_id;
} }
@@ -213,13 +175,7 @@ function qs(params: Record<string, string | number | undefined>): string {
return parts.length ? `?${parts.join('&')}` : ''; return parts.length ? `?${parts.join('&')}` : '';
} }
/** Bindings clientKBDB_BASE_URL fallback D1 /** 從 Bindings 建 client。KBDB_BASE_URL 未設時拋錯(不准 fallback 直連 D1)。 */
* KBDB_BASE_SVCservice binding workers.devworkers.dev export function makeKbdbClient(env: { KBDB_BASE_URL?: string; KBDB_INTERNAL_TOKEN?: string }): KbdbClient {
* CF error 1042 退 fetch dev / */ return new KbdbClient(env.KBDB_BASE_URL ?? '', env.KBDB_INTERNAL_TOKEN);
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);
} }
+1 -5
View File
@@ -18,13 +18,9 @@ export const TRIPLET_SLOTS = [
// source_uri+content_hash 承載 ingest idempotency(按 source_uri 分組 deprecate)。 // source_uri+content_hash 承載 ingest idempotency(按 source_uri 分組 deprecate)。
// source_anchor 供 get_source 精準回跳原文(T3.7)。 // source_anchor 供 get_source 精準回跳原文(T3.7)。
'status', 'superseded_by', 'source_uri', 'content_hash', 'source_anchor', 'status', 'superseded_by', 'source_uri', 'content_hash', 'source_anchor',
// 謂詞向量化打標(ingest#1 升格,2026-06-26):ingest 打標、base embed 模組讀標執行;graph 不算向量。
'predicate_embed',
]; ];
// glossT3.2b):一句話描述,供「詞+gloss」語義 normalize 的 embedding 對象。 // glossT3.2b):一句話描述,供「詞+gloss」語義 normalize 的 embedding 對象。
// embedingest#1 升格,2026-06-26):向量化打標,base embed 模組讀標執行;graph 不算向量。 export const ENTITY_SLOTS = ['canonical', 'aliases_json', 'entity_type', 'owner', 'gloss'];
// 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 = [ export const ENTITY_PENDING_SLOTS = [
'raw_name', 'candidate_entity_id', 'candidate_canonical', 'similarity', 'raw_name', 'candidate_entity_id', 'candidate_canonical', 'similarity',
]; ];
+2 -8
View File
@@ -54,19 +54,13 @@ 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');
// owner 必經:confirm 會 addAlias(重建 entity record),缺 owner→400 不寫無主資料。 await confirmPendingAlias(makeKbdbClient(c.env), id);
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');
// owner 必經:reject 會 createEntity,缺 owner→400 不寫無主資料。 const newEntity = await rejectPendingAlias(makeKbdbClient(c.env), id);
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 });
}); });
+2 -8
View File
@@ -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().min(1), // 必填:缺→400(資料不可無主) owner_id: z.string().optional(),
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,13 +103,10 @@ 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'],
@@ -118,11 +115,8 @@ 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, owner_id); const result = await ingestEnvelope(makeKbdbClient(c.env), env);
return c.json(result, 200); return c.json(result, 200);
}, },
// strict() 驗證失敗(如送禁止欄位 bridge_score)→ 422,不是預設 400。 // strict() 驗證失敗(如送禁止欄位 bridge_score)→ 422,不是預設 400。
-5
View File
@@ -6,11 +6,6 @@ export type Bindings = {
KBDB_BASE_URL?: string; // 基本盤 arcrun/kbdb API 網址(leo: 可設定,先留空) KBDB_BASE_URL?: string; // 基本盤 arcrun/kbdb API 網址(leo: 可設定,先留空)
KBDB_INGEST_URL?: string; // ingest 服務網址(refresh 代轉對象;T4 就緒前留空) KBDB_INGEST_URL?: string; // ingest 服務網址(refresh 代轉對象;T4 就緒前留空)
KBDB_INTERNAL_TOKEN?: string; 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; ENVIRONMENT: string;
API_KEY?: string; API_KEY?: string;
}; };
+4 -25
View File
@@ -39,21 +39,6 @@
INDEX 是**所有檢索角度的入口**,不只標籤。原文是唯讀 SSoT,wiki 是改寫過的記憶。 INDEX 是**所有檢索角度的入口**,不只標籤。原文是唯讀 SSoT,wiki 是改寫過的記憶。
新增角度只要在這裡加一節(如「決策角度」「原則角度」),指向對應 cards 或 push 檔——**不必新增實體特殊檔**。 新增角度只要在這裡加一節(如「決策角度」「原則角度」),指向對應 cards 或 push 檔——**不必新增實體特殊檔**。
### 快速導航(本專案速覽)
**這個專案是什麼**KBDB-graph —— KBDB 的 graph 插件(triplet 採集 + graph 查詢),類比 Apache AGE 之於 Postgres。已抽成獨立 public repo `uncle6me-web/kbdb-graph-plugin`(leo 產權)。基本盤(block CRUD,D1 三表)在 `arcrun/kbdb`,不在這。
**動工前必讀**
- `docs/HANDOFF-kbdb-plugin.md` —— 本目錄專屬交棒。
- 上游約束見 `CLAUDE.md` 最頂 + `github.com/uncle6me-web/InkStoneCo`
**文件去哪找**
- SDDdesign+tasks)→ `docs/3-specs/`(現有:kbdb-graph-extraction、blocks-edit-api、plugin-install、arcrun-key-auth
- 歷史記錄 / bug 復盤 → `docs/5-records/`PATCH 403 bug、upsert feature request
- 分類規則全表 → `docs/README.md`
**絕對限制**:本目錄只做 graph 插件 / **API-as-Wall(插件絕不碰表、零 SQL、零 migration、零建表)** / 部署繞開 GitHub、禁跨 repo Actions / 樂高法(actions < 100 行)。
### 標籤角度(按 `TAXONOMY.md` 的軸聚類,指向桶子索引) ### 標籤角度(按 `TAXONOMY.md` 的軸聚類,指向桶子索引)
```markdown ```markdown
@@ -64,17 +49,11 @@ INDEX 是**所有檢索角度的入口**,不只標籤。原文是唯讀 SSoT
- [[ai/00-INDEX]] — AI 協作(M 卡) - [[ai/00-INDEX]] — AI 協作(M 卡)
``` ```
(尚未建 cards,現有決策見下「決策角度」。 ### 決策角度(取代舊 decisions-summary.md 的視圖
### 決策角度(取代舊 decisions-summary.md 的視圖;完整脈絡見 `decisions-summary.md` + `docs/2-architecture/decisions/` ```markdown
- [[某決策卡]] — 一句話結論(YYYY-MM-DD
- **KBDB-graph 定位**2026-06-13)— 本 repo = KBDB 的 graph 插件,獨立成 repo,類比 AGE 之於 Postgres。 ```
- **🔒 KBDB 鐵律 + API-as-Wall**2026-06-14,最高原則)— 插件絕不碰表、零 SQL、零 migration,讀寫全走基本盤 HTTP API;新類型=建 template+填 slot,永不建表。
- **獨立 repo 名**2026-06-14)— public `uncle6me-web/kbdb-graph-plugin`,無 Actions。
- **掛載介面 = 基本盤 API(非共用 D1)**2026-06-14,推翻原判斷)— 圖在插件層記憶體從 records 組裝,不直接 SQL、不建 VIEW。
- **安裝契約:KBDB_BASE_URL 安裝時 AI 填**2026-06-14)— AI 查 CF subdomain 拼 URL → `wrangler secret put` + `deploy`;本地測試用 `.dev.vars`
- **~~萬物皆 Blockv3~~**2026-02-28 提出,2026-06-14 淘汰)— 帶獨立 blocks 表的「v3」是違規殘留已刪;基本盤真身 = arcrun/kbdb 3 表。
- **避免再被 GitHub flag**(上游鐵律)— 禁跨 repo 自動同步 Actions;部署繞開 GitHub。
> 結構:INDEX(多角度入口)→ `cards/<bucket>/00-INDEX.md`(桶子索引,固定名)→ 概念原子卡。 > 結構:INDEX(多角度入口)→ `cards/<bucket>/00-INDEX.md`(桶子索引,固定名)→ 概念原子卡。
> 指 `00-INDEX` **一律帶路徑** `[[bucket/00-INDEX]]`(固定名跨桶撞名);卡片間用裸 `[[卡名]]` > 指 `00-INDEX` **一律帶路徑** `[[bucket/00-INDEX]]`(固定名跨桶撞名);卡片間用裸 `[[卡名]]`
+20
View File
@@ -40,6 +40,26 @@
**原因**:當初 monorepo→多 worker 的 Actions 自動同步 + 高頻 API 害帳號被 flag。 **原因**:當初 monorepo→多 worker 的 Actions 自動同步 + 高頻 API 害帳號被 flag。
**詳細**InkStoneCo 頂層 CLAUDE.md。 **詳細**InkStoneCo 頂層 CLAUDE.md。
## ingest 取代用「先 append 後 deprecate」— 2026-06-26(總管採納,優於頂層原寫法)
**結論**`POST /triplets/ingest` 同 source.uri 出新 content_hash 時,**先 append 新批 active,再翻舊批 status=deprecated**。非頂層 SDD 原寫的「deprecate-then-append」。
**原因**:先翻舊批會出現「全無 active」的中途空窗(若 append 失敗);先 append 保證任一時刻都有 active 集合可查。總管已回頭把頂層 design §5 對齊成此寫法。
**詳細**`docs/3-specs/ingest-contract/design.md` §5issue #1 總管確認 comment。
## idempotency 鍵存進 triplet template slot — 2026-06-26
**結論**triplet template 增 `source_uri`+`content_hash`+`source_anchor` 供 get_sourceslot 承載 ingest idempotency;按 `source_uri` 分組 deprecate、同 hash no-op。`source_block_id`Logseq)不夠用。
**原因**:純 plugin 領域 slotbase 不需知道),合鐵律(slot 非建表);contract `ingest-candidate.json` 本就有 source.uri+content_hash,落地存進 slot 是自然動作,**不改 contract**。
**詳細**`design.md` §3/§5issue #1
## ensureTemplate 改 slot-diff 補丁 — 2026-06-26
**結論**`ensureTemplate` 命中既有 template 時,比對 slot 差集 → 缺的走 base `PATCH /templates/:id` 補上(**取代原 early-return**)。
**原因**:原 early-return 不補新 slot → seed 後新增的 slotstatus/gloss…)對既有環境永遠進不來。slot-diff 讓既有+全新環境都自動收斂,免另跑一次性遷移腳本。
**詳細**`src/lib/kbdb-client.ts`issue #1 前置警示1。
## refresh 純被動代轉(人發起,守 fan-out 紅線)— 2026-06-26
**結論**`POST /graph/refresh` 只代轉 ingest 重抓+萃,**只能人發起調用觸發**,禁排程/webhook/cron 自動 refresh。graph 自己不抓不萃(ingest 純餵食器職責)。
**原因**:自動 fan-out 正是當初害 GitHub flag 的模式。`KBDB_INGEST_URL` 未設時誠實回 `forwarded:false`,不假綠。
**詳細**`src/actions/graph-refresh.ts`issue #1 T3.6b。
--- ---
格式: 格式:
+10 -4
View File
@@ -51,10 +51,16 @@
原因: 基本盤 = D1 only(免費、無信用卡);embed 是可選加購層。插件混進來會破壞分層。 原因: 基本盤 = D1 only(免費、無信用卡);embed 是可選加購層。插件混進來會破壞分層。
日期: 2026-06-14 日期: 2026-06-14
⚠️ MISTAKE: 補對齊/功能 PR 混進 template 基建遷移 → 撞已 merge 的遷移、害衝突 ⚠️ MISTAKE: 照「移除某端點」字面就整檔刪 action(沒查反向依賴)
症狀: PR#3receiver Zod 補對齊)從 PR#2 merge【前】的分支切 → 帶了一整批 template 1.9.x 遷移檔(.claude/→system-dev/40 個)。PR#2 已把那批搬進 main → PR#3 重複撞 = CONFLICTING/DIRTY,且真正的補對齊改動被淹沒 症狀: issue 寫「移除 search-query.ts 代理 base 關鍵字那條」→ 差點整個刪 search-query.ts,但 search-suggest.ts 內部依賴 keywordSearch helper,刪了會連帶弄壞 suggest
正確做法: 功能/補對齊 PR 只放該功能的改動;template/基建遷移單獨一筆 PR。撞衝突時別在舊分支硬解一堆遷移衝突 → 從最新 origin/main 重切乾淨分支、只 cherry-pick 該功能的 code commit、force-with-lease 覆蓋 PR 分支(PR 自動更新、不用關掉重開)。切分支前先確認 base 是不是落後於已 merge 的東西 正確做法: 動手前 grep 反向依賴(誰 import 它)。「移除公開端點」≠「刪內部 helper」——收斂 POST /search 為 suggest-onlykeywordSearch 留作 suggest 的內部建構塊
原因: 分支從「即將被 merge 的另一支」之前切,會把對方的改動也一起帶上;對方 merge 後兩份就撞。核心改動本身不衝突,衝突全來自混進來的重複遷移 原因: 把「移除對外能力」誤當「刪實作檔」。一個 helper 常同時餵公開端點和內部功能,刪檔前必須看清它還餵誰
日期: 2026-06-26
⚠️ MISTAKE: tsc 報一堆錯就以為自己改壞了
症狀: 跑 `tsc --noEmit` 看到 index.ts/entities.ts/graph.ts 一片 Hono 型別錯,疑心是這次改動破壞編譯。
正確做法: 先確認專案的 gate 是什麼——本 repo gate = `vitest run`package.json test script),不是 tsc。那些 Hono 型別錯是既有 noise(改動前就在)。判斷「我是否弄壞」要對齊專案真正的驗收命令,不是隨手挑一個工具。
原因: 預設 tsc 乾淨=健康,但此 repo tsc 從來沒乾淨過;拿錯的 gate 當基準會誤判。
日期: 2026-06-26 日期: 2026-06-26
--- ---
+19 -55
View File
@@ -5,75 +5,39 @@
--- ---
## 最新(2026-06-26issue #1 補對齊 — receiver Zod 追上 contractPR #3 已 merge ## 最新(2026-06-26issue #1 T3 — ingest 寫入端 + graph 端 API
[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 PR [#2](https://github.com/uncle6me-web/kbdb-graph-plugin/pull/2)(分支 `feat/ingest-contract-sdd`4 commits,未 merge)。對應 [issue #1](https://github.com/uncle6me-web/kbdb-graph-plugin/issues/1)(頂層 mira-dissolve T3
**起因(契約漂移)**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)。 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)。 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 查詢。 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)。 4. ✅ **get_source + refresh**T3.6/3.7):`GET /graph/source/:name``POST /graph/refresh`(純被動代轉)、keyword 收斂(3.6d)。
驗證:`vitest run` 19 passedmock);zero SQL / 無 D1·Vectorize·AIdry-run 乾淨;action ≤100 行。 驗證:`vitest run` **19 passed**mock);zero SQL / 無 D1·Vectorize·AI`wrangler deploy --dry-run` bundle 乾淨;所有 action ≤100 行。
> 註:base `PATCH /records/:id` 已就緒(Arcrun #6 closed),ingest deprecate 即用此。
---
## 已完成(2026-06-14:按 leo 鐵律全面改寫 + 獨立成 repo)
HANDOFF 5 項待辦全做完:
1. ✅ **改寫成走基本盤 API**(核心):21 個違規直接 SQL action 全改走 `src/lib/kbdb-client.ts`。寫 triplet=`POST /records`(template=triplet);查圖=取 triplet records 在**插件層記憶體**組鄰接表跑演算法(不靠 DB VIEW)。entity 正規化降級 exact match。
2. ✅ **刪所有 migrations**(插件零建表)+ 清基本盤 action/routeblock-*/entry-crud/record-crud/tag/profile/admin/partner/convert/tasks/personality)。
3. ✅ **獨立成 repo**`git init` + 推 **public `uncle6me-web/kbdb-graph-plugin`**(無 Actions)。
4. ✅ **裁剪 CLAUDE.md**:移除 KBDB v3 基本盤規範,只留 graph 插件 + 🔒 鐵律 + 安裝契約。
5. ✅ **部署繞開 GitHub**`scripts/install.sh`wrangler 直推),無 .github/workflows。
驗證:`wrangler deploy --dry-run` 通過(bundle 無 D1/AI/Vectorize);`vitest run` 10 passedmock client);全違規掃描零命中;所有 action ≤100 行。
## 同場修正的 SDD 錯誤判斷
design.md 原本「讀現狀(21 個直接 SQL)推翻鐵律、問要不要共用 D1」是**讀違規現狀推翻規則**的錯。已改正為 **API-as-Wall(走 API,非共用 D1,零建表/零 SQL)**,並記進 mistakes.md。
## 正在做 / 阻擋 ## 正在做 / 阻擋
- PR #2T3)、PR #3(補對齊)皆已 merge 進 main。graph repo 端實作面收斂,**本 repo 無剩餘可單獨做的 task** - [🔄] **PR #2 待 review/merge** — 阻擋點:等 leo/總管。graph repo 端 T3 實作面收斂,剩跨 repo 接通
- [🔄] 剩三項皆「不在 graph 手上」,等跨 repo 接通 / 部署(見下)。
## 下次 session 第一件事 ## 下次 session 第一件事
main 已含 T3 + 補對齊。本 repo 端無待辦——下次動工多半是**新交辦**或**跨 repo 接通就緒後接 MCP 薄殼** **確認 PR #2 狀態**:若已 merge → 切回 `main` 拉新狀態;若有 review 意見 → 在 `feat/ingest-contract-sdd` 上改
若要實際部署:等基本盤 `arcrun-kbdb` 上線有網址後跑 `bash scripts/install.sh`(自動查 CF subdomain 拼 `KBDB_BASE_URL` → secret → deploy)。現不空跑(避免上線打不到基本盤的殼)。 本 repo 端 T3 無剩餘可單獨做的 task(其餘待跨 repo / 部署,見下)。
## 待負責人確認 / 跨 repo 接通(全通才結 issue #1 ## 待負責人確認
- **MCP 註冊薄殼** — 圖工具(traverse/neighbors/source/refresh)併入 arcrun `u6u-mcp-server`。等:總管協調 arcrun**不另起 graph MCP**;待 Arcrun #7 部署驗。graph 端 HTTP API 已備好 - **PR #2 merge** — 等:leo/總管 review
- **refresh 端到端** — 等:ingest repoT4)部署 + 設 `KBDB_INGEST_URL`;未設時誠實回 `forwarded:false` - **MCP 註冊薄殼**(圖工具 traverse/neighbors/source/refresh 併入 arcrun `u6u-mcp-server`)— 等:總管協調 arcrun**不另起 graph MCP**。graph 端 HTTP API 已備好
- **semantic normalize** — 仍 exact-only,留接口;等:base embedArcrun #7code done 待部署)。**補對齊已把向量化打標(embed/predicate_embed/gloss/aliases)落地進 slot 供 base 讀**,base 模組就緒即可接。
## 已知缺口[→arcrun],待基本盤補) ## 已知問題 / 缺口
- 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。 | refresh 端到端走通 | 🟡 | 待 ingest repoT4)部署 + `KBDB_INGEST_URL` 設定;未設時誠實回 `forwarded:false` |
| semantic normalize | 🟡 | 仍 exact-only,留接口;待 base embedArcrun #7code done 待部署) |
| MCP 工具實際可用 | 🟡 | graph 端 API 備好,待 arcrun 註冊薄殼 |
| base `DELETE /records/:id` | ⚪ | 仍無 → triplet/entity delete 為 soft(不硬刪);死資料清除原則見頂層 T8 |
> 註:base `PATCH /records/:id` **已就緒**Arcrun #6 closed,本 session ingest deprecate 即用此)。舊 status 列的「base 無 PUT /records/:id」缺口已部分解除。
+8 -20
View File
@@ -5,29 +5,17 @@ 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 查回(大小寫不敏感)owner slot 帶真 owner', async () => { it('建立後可 exact 查回(大小寫不敏感)', async () => {
const c = mockClient(); const c = mockClient();
const ent = await createEntity(c, 'InkStone', 'leo'); await createEntity(c, 'InkStone');
const found = await findEntityByName(c, 'inkstone', 'leo'); const found = await findEntityByName(c, 'inkstone');
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', 'leo'); await createEntity(c, 'A');
await createEntity(c, 'B', 'leo'); await createEntity(c, 'B');
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']);
}); });
@@ -36,8 +24,8 @@ describe('entity-crud', () => {
describe('normalizeEntity', () => { describe('normalizeEntity', () => {
it('已存在回 canonical,不存在建新回原值', async () => { it('已存在回 canonical,不存在建新回原值', async () => {
const c = mockClient(); const c = mockClient();
await createEntity(c, 'InkStone', 'leo'); await createEntity(c, 'InkStone');
expect(await normalizeEntity(c, 'INKSTONE', 'leo')).toBe('InkStone'); expect(await normalizeEntity(c, 'INKSTONE')).toBe('InkStone');
expect(await normalizeEntity(c, '新公司', 'leo')).toBe('新公司'); expect(await normalizeEntity(c, '新公司')).toBe('新公司');
}); });
}); });
+3 -3
View File
@@ -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
View File
@@ -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 — CD 孤立連 A(owner 必經:所有寫入帶真 owner) // A — B — CD 孤立連 A
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B', owner_id: 'leo' }); await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B' });
await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C', owner_id: 'leo' }); await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C' });
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D', owner_id: 'leo' }); await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D' });
} }
describe('graph-nodes', () => { describe('graph-nodes', () => {
-4
View File
@@ -15,8 +15,6 @@ 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);
@@ -63,8 +61,6 @@ 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;
+4 -14
View File
@@ -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', owner_id: 'leo' }); const r = await createTriplet(c, { subject: 'InkStone', predicate: '是', object: '創業 OS' });
expect(r.id).toBeDefined(); expect(r.id).toBeDefined();
expect(r.subject).toBe('InkStone'); expect(r.subject).toBe('InkStone');
@@ -14,23 +14,13 @@ 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', owner_id: 'leo' }); await createTriplet(c, { subject: 'KBDB', predicate: '使用', object: 'D1' });
await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X', owner_id: 'leo' }); await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X' });
const { triplets, count } = await queryTriplets(c, { subject: 'KBDB' }); const { triplets, count } = await queryTriplets(c, { subject: 'KBDB' });
expect(count).toBe(1); expect(count).toBe(1);
@@ -39,7 +29,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', owner_id: 'leo' }); for (let i = 0; i < 5; i++) await createTriplet(c, { subject: `s${i}`, predicate: 'p', object: 'o' });
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);
}); });
+7 -117
View File
@@ -1,13 +1,10 @@
// ingest 寫入端 — 走 mock KbdbClientAPI-as-Wall),零 SQL、不打網路。 // ingest 寫入端 — 走 mock KbdbClientAPI-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 },
@@ -22,7 +19,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, {});
@@ -33,46 +30,11 @@ 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', () => { 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' }]), OWNER); 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); const res = await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]));
expect(res.skipped).toBe(true); expect(res.skipped).toBe(true);
const { triplets } = await queryTriplets(c, {}); const { triplets } = await queryTriplets(c, {});
@@ -83,8 +45,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' }]), OWNER); await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]));
const res = await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]), OWNER); const res = await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]));
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 1 }); expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 1 });
// active-only 查詢只見新批。 // active-only 查詢只見新批。
@@ -123,83 +85,11 @@ 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, OWNER);
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 0 });
const { triplets } = await queryTriplets(c, {});
expect(triplets.length).toBe(1);
// node 打標落地成 entity recordgloss/aliases/embed 標示透傳供 base 讀)。
const entities = await c.listRecordsByTemplate('entity');
const gr = entities.find((e) => e.values.canonical === 'Graph RAG');
expect(gr?.values.node_id).toBe('graph-rag.md');
expect(gr?.values.embed).toBe('true');
expect(JSON.parse(gr!.values.aliases_json!)).toEqual(['圖譜 RAG']);
expect(gr?.values.gloss).toBe('關係遍歷檢索');
const jensen = entities.find((e) => e.values.canonical === '黃仁勳');
expect(jensen?.values.embed).toBe('false'); // 明確 false 透傳
});
it('同 id 的 node 被多次帶入 → 去重,只存一筆 entity(一卡一 node', async () => {
const c = mockClient();
const env: IngestEnvelope = {
source: { uri: 'github:uncle6me-web/wiki@dup.md', content_hash: 'hd' },
extractor: { model: 'm', tier: 'deep' },
nodes: [
{ name: 'Graph RAG', id: 'graph-rag.md' },
{ name: 'Graph RAG(別名)', id: 'graph-rag.md' }, // 同 id → 同卡,不重建
],
triplets: [{ subject: 'Graph RAG', predicate: 'r', object: 'X' }],
};
await ingestEnvelope(c, env, OWNER);
const entities = await c.listRecordsByTemplate('entity');
expect(entities.filter((e) => e.values.node_id === 'graph-rag.md').length).toBe(1);
});
it('帶真正 graph 領域禁送欄位(bridge_score)→ 仍 422', () => {
const polluted = {
source: { uri: 'u', content_hash: 'h' },
extractor: { model: 'm', tier: 'deep' },
nodes: [{ name: 'A', embed: true }],
triplets: [{ subject: 'A', predicate: 'r', object: 'B', predicate_embed: true, bridge_score: 3 }],
};
expect(IngestEnvelopeSchema.safeParse(polluted).success).toBe(false);
});
it('node 帶 graph 領域禁送欄位(clusters)→ 仍 422', () => {
const polluted = {
source: { uri: 'u', content_hash: 'h' },
extractor: { model: 'm', tier: 'deep' },
nodes: [{ name: 'A', embed: true, clusters: ['c1'] }],
triplets: [{ subject: 'A', predicate: 'r', object: 'B' }],
};
expect(IngestEnvelopeSchema.safeParse(polluted).success).toBe(false);
});
});
describe('ingestEnvelope — rollback(翻回 status', () => { 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' }]), OWNER); await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]));
await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]), OWNER); await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]));
// 取出被 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 });
+6 -20
View File
@@ -10,29 +10,15 @@ workers_dev = true
[vars] [vars]
ENVIRONMENT = "development" ENVIRONMENT = "development"
# 基本盤 arcrun/kbdb API 網址:務必只透過 `wrangler secret put KBDB_BASE_URL` 設定 # 基本盤 arcrun/kbdb API 網址leo 2026-06-14:做成可設定,先留空)
# 2026-07-03 補跑實測發現的坑:若這裡也宣告 KBDB_BASE_URL(哪怕留空字串), # 部署前用 `wrangler secret put` 或在此填入,例如 https://arcrun-kbdb.<acct>.workers.dev
# `wrangler deploy` 會用這個 [vars] 值覆蓋掉已存在的 remote secretvars 優先權 > secret), KBDB_BASE_URL = ""
# 導致部署後 KBDB_BASE_URL 被打回空字串、插件連不到基本盤。故此鍵不可出現在 [vars]。
[alias] [alias]
"zod/v3" = "zod" "zod/v3" = "zod"
"zod/v4" = "zod" "zod/v4" = "zod"
"zod/v4-mini" = "zod" "zod/v4-mini" = "zod"
# 2026-07-03 補跑實測發現的坑:workers.dev 是共享 zoneWorker 直接 fetch() [[routes]]
# 另一個 *.workers.dev Worker 會被 CF 擋(error code 1042loop-prevention)。 pattern = "kbdb-graph.finally.click"
# self-hosted 帳號通常沒自訂域名可繞,正規解法 = Service BindingCF 內部直連, custom_domain = true
# 不經公開網路、不受 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