Files
Arcrun/registry/examples/km-wiki-ingest/workflow.yaml
T
kbdb-cc c6932b0088 feat(km-wiki-ingest): 重做成跑在 cypher 上的 workflow(走 cypher binding,汰換 service-binding drainer,D28)
把知識庫 ingest 從 standalone drainer(誤用 Service Bindings)重表達為 cypher workflow:

- workflow.yaml(Phase 0 cron drain):watch_cron→load_cursor→list_cards→pick_card(code)
  →fetch_card→parse_card(code)→upsert_entry→save_cursor→post_envelopes→post_one_envelope。
  線性 pipe,跨-worker 全走 cypher binding(零件節點),零 service binding。
- workflow.delta.yaml(Gitea webhook 穩態):collect_changed(code)→foreach card→fetch/parse/upsert/foreach envelope。
- code/kbdb/graph 接法:code=canonical `code` 零件(arcrun-code);kbdb/graph=http_request 零件打
  /entries/ingest、/triplets/ingest(server 端冪等);Gitea=http_request。
- 平台端最小補丁(各需 gated 部署):
  1) cypher-executor component-loader:WASM_HTTP_RUNNER_IDS 加 'code'(canonical→arcrun-code,cypher binding 正解)。
  2) kbdb base:POST /entries/ingest(page_name+content_hash 冪等 upsert,對稱 graph /triplets/ingest;
     因 flow DSL 無資料條件分支,把 create/patch/skip 冪等推到 server 端)。
- drainer/DEPRECATED.md:標舊 standalone worker 退役計畫(新版穩定後 wrangler delete)。
- DEPLOY.md:部署順序、cron/webhook 掛法、subdomain 對齊、驗收與退役。

本輪不部署(待總管/leo 審架構)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 12:59:13 +00:00

529 lines
28 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: km_wiki_ingest_drain
description: >
Phase 0 限速 draincron 每 tick 一卡)——**跑在 cypher-executor 上的 workflow,全程走 cypher binding
零 Service Bindings**D28)。來源=Gitea repo 的 system-dev/wiki/cards/**/*.md(人工精耕卡,無 LLM)。
卡片 → 通用 `code` 零件機械解析成 entry + triplet envelope → 冪等寫 KBDB base/entries/ingest
與 graph/triplets/ingest)。反覆跑直到全庫 drain 完;穩態改走 webhook(見 workflow.delta.yaml)。
# ═══════════════════════════════════════════════════════════════════════════
# 架構(D28 訂正):這**不是** standalone drainer worker,是 cypher workflow。
# - 跨-worker 呼叫一律走 **cypher binding**=把每個外部呼叫表達成一個「零件節點」,
# 由 cypher-executor 的 component-loader 解析執行。**不用 Service Bindings**
# service binding 只准「零件內部把幾個 wasm 綁成複合零件」的零件等級,見 D28)。
# - code/kbdb/graph 的接法(皆 cypher binding,非 service binding):
# code → canonical 零件 `code`arcrun-codeQuickJS 沙箱;純函式解析)
# kbdb → `http_request` 零件打 {{kbdb_url}}/entries/ingestbase 的 server 端冪等 upsert
# graph → `http_request` 零件打 {{graph_url}}/triplets/ingestgraph 的 server 端 per-source 冪等)
# Gitea → `http_request` 零件(外部 API,本就走公網)
# - 觸發:cron 零件節點(cypher scheduled() 每分鐘掃 cron-idx 觸發本 workflow)。
#
# ⚠️ 部署前置(見 DEPLOY.md):
# 1) cypher-executor 需能解析 `code` 零件 —— 目前 component-loader 的 WASM_HTTP_RUNNER_IDS
# 白名單未含 `code`(本分支已補 1 行;或改用 code 的 workers.dev 完整 URL 當 component)。
# 2) KBDB base 需有 POST /entries/ingestpage_name+content_hash 冪等 upsert)——本分支已加,
# 與 graph /triplets/ingest 對稱。若不部署它:改用「lookup→decide→create/patch」多節點版(較繁,
# 且 flow DSL 無資料條件分支,需靠 ON_SUCCESS/ON_FAIL 兩路 hack,故不建議)。
#
# ── 為什麼「一 tick 一卡」=根治 07_01 的 Too many subrequests(同原設計,不變)──
# graph 處理一次 /triplets/ingest 的 subrequest = 7 + 4*N_triplets + M_nodes + D_deprecated
# 07_01 炸點 N=11,M=10 → 61 > 50。對策:一卡一 tick(天然小批)+ code 節點超大卡以 source_uri anchor 分段。
# ═══════════════════════════════════════════════════════════════════════════
flow:
# 線性 pipe(無資料條件分支——flow DSL 不支援 IF 條件,僅 ON_SUCCESS/ON_FAIL/FOREACH)。
- "watch_cron >> ON_SUCCESS >> load_cursor" # cron tick 觸發
- "load_cursor >> ON_SUCCESS >> list_cards" # 讀游標(kbdb entry,字串 body
- "list_cards >> ON_SUCCESS >> pick_card" # 列卡(Gitea git tree recursive
- "pick_card >> ON_SUCCESS >> fetch_card" # code:游標+tree → 下一張卡(到底回捲)
- "fetch_card >> ON_SUCCESS >> parse_card" # 抓卡片全文(Gitea raw
- "parse_card >> ON_SUCCESS >> upsert_entry" # code:卡片 md → entry + envelope(純函式)
- "upsert_entry >> ON_SUCCESS >> save_cursor" # 卡片 → base entry(冪等 upsertembed=true
- "save_cursor >> ON_SUCCESS >> post_envelopes" # 進游標(樂觀;冪等使其僅為掃描指標)
- "post_envelopes >> 對每個 envelope >> post_one_envelope" # 逐段 POST graph /triplets/ingest
config:
# ── 1) cron 觸發節點 ─────────────────────────────────────────────────────────
# webhooks-named 部署時 extractCronExpr 會抓這裡的 cron_expr 註冊進 cron-idx
# cypher scheduled() 每分鐘比對觸發(不需另設 CF cron;cypher 本身已有每分鐘 tick)。
watch_cron:
component: cron
cron_expr: "*/2 * * * *"
description: "每 2 分鐘 drain 一張卡(限速慢推,避免 CF 額度與 subrequest 壓力)"
# ── 2) 讀游標(kbdb base entrypage_name 當鍵)。回應 body 是字串,交由 pick_card(code) JSON.parse。──
load_cursor:
component: http_request
method: GET
url: "https://arcrun-kbdb.uncle6-me.workers.dev/entries?page_name=cursor:km_wiki_ingest_drain:Leo/notes&owner_id=leo"
# ── 3) 列卡(Gitea git tree recursive)。Authorization 走 credential 注入(見 DEPLOY §creds)。──
list_cards:
component: http_request
method: GET
url: "https://git.uncle6.me/api/v1/repos/Leo/notes/git/trees/main?recursive=true"
headers:
Authorization: "token {{credential.gitea_token}}"
Accept: "application/json"
# ── 4) 挑下一張卡(純函式 code 零件)──
# input:游標 body + tree body + cards_root。output(→ .data):
# { rel_path, cursor_content_json, total, wrapped, has_card }
# 到底回捲(continuous drain);靠 entry/graph 冪等,未改卡 cheap skip。
# repo 內零卡才 throw(→ 節點 success:false → 下游 ON_SUCCESS 不觸發,該 tick no-op)。
pick_card:
component: code
code: |
const root = (input.cards_root || 'system-dev/wiki/cards').replace(/\/$/, '') + '/';
let last_path = '', cycle = 0;
try {
const cj = JSON.parse(input.cursor_body || '{}');
const e = (cj.entries || [])[0];
if (e && e.content) { const s = JSON.parse(e.content); last_path = s.last_path || ''; cycle = s.cycle || 0; }
} catch (_) { /* 游標缺/壞 → 從頭 */ }
let tree = {};
try { tree = JSON.parse(input.tree_body || '{}'); } catch (_) {}
const cards = (tree.tree || [])
.filter((x) => x.type === 'blob' && x.path.startsWith(root) && x.path.endsWith('.md'))
.filter((x) => { const b = x.path.split('/').pop() || ''; return b !== '.gitkeep' && !b.startsWith('00-INDEX'); })
.map((x) => x.path)
.sort();
if (cards.length === 0) throw new Error('no cards under ' + root + 'repo 空或路徑錯)');
let idx = cards.findIndex((p) => p > last_path);
let wrapped = false;
if (idx < 0) { idx = 0; wrapped = true; }
const rel_path = cards[idx];
return {
rel_path,
total: cards.length,
wrapped,
truncated: tree.truncated === true,
has_card: true,
cursor_content_json: JSON.stringify({ last_path: rel_path, cycle: cycle + (wrapped ? 1 : 0), processed_at: Math.floor(Date.now() / 1000) }),
};
input:
cursor_body: "{{load_cursor.data.body}}"
tree_body: "{{list_cards.data.body}}"
cards_root: "system-dev/wiki/cards"
limits:
timeout_ms: 3000
max_output_bytes: 2097152
# ── 5) 抓卡片全文(Gitea raw)。parse_card 讀 {{fetch_card.data.body}}。──
fetch_card:
component: http_request
method: GET
url: "https://git.uncle6.me/api/v1/repos/Leo/notes/raw/{{pick_card.data.rel_path}}?ref=main"
headers:
Authorization: "token {{credential.gitea_token}}"
parse_card:
component: code
code: |
// km-wiki-ingest — 機械式卡片→(entry + triplet envelope) 轉換核心(無 LLM、純函式)
// ---------------------------------------------------------------------------
// 取代舊 `kbdb-ingest-plugin/scripts/ingest-cli.mjs` 的 raw→Haiku 路:
// 舊路 = 讀裸筆記 → 呼叫 Haiku 萃 (s,p,o) → envelope(有 LLM、非決定性、耗 token)。
// 新路 = 讀「已精耕卡片」(`system-dev/wiki/cards/**/*.md`)→ 直接解析卡片內既有的
// `## 實體`(節點)、`## 關聯` 的 typed-edge`A >> 關係 >> B`)與 `[[wikilink]]`
// → entry + triplet envelope。純機械、決定性、零 token。
//
// 這支=通用 `code` 零件(Arcrun#10sandbox inline JS)承載的解析邏輯本體。
// workflow.yaml 的 parse_card 節點把本檔的 planCard 邏輯內聯進 code 零件的 config
// (去 import/export、raw NUL 分隔符改 u0000 escape、改用 code 沙箱注入的 sha256);
// 不再鑄 domain 零件 km_wiki_card_parseArcrun#10 裁定:一次性解析走通用逃生口)。
// 本檔續留作「該內聯 JS 的權威來源 + 可單元測試的參考實作」(純函式、stdin→stdout JSON、無 fs/網路)。
//
// 對齊契約:kbdb-ingest-plugin/contracts/ingest-candidate.jsonenvelope 形狀 / 禁止欄位)。
// 對齊頂層 SDD:卡片→entrymetadata.embed=true,走 base API)、wikilink→triplet(走 graph)。
//
// 鐵律:不碰儲存、不算向量、不建表。這支只「產出將寫入什麼」,實際 HTTP 由 workflow 打。
// (import 移除:code 沙箱提供注入的 sha256 builtin)
// --- CF subrequest 預算(防「Too many subrequests by single Worker invocation」,07_01 根因)---
//
// graph worker 處理一次 POST /triplets/ingest 時,對 base 的每次 fetch = 1 subrequest。
// 精確拆帳(讀 kbdb-graph-plugin/src/actions/triplet-ingest.ts + triplet-crud.ts + templates.ts):
// ingestEnvelope = ensurePluginTemplates(3) + listRecordsByTemplate(1)
// + Σ triplet [ createTriplet → ensurePluginTemplates(3) + createRecord(1) = 4 ]
// + persistNodes [ ensurePluginTemplates(3) + Σ node createRecord(1) ]
// + Σ deprecated updateRecord(1)
// ⟹ subreq(envelope) = 7 + 4*N_triplets + M_nodes + D_deprecated
//
// 07_01 實測炸點:N=11, M=10, D=0 → 7+44+10 = 61 > 50CF 免費/bundled 上限)→ 炸半殘。
//
// 對策 = 「一卡一 tick、每 envelope 壓在預算下、超大檔以 source_uri anchor 分段」。
const SUBREQ_CEILING = 50; // CF 單次 Worker invocation subrequest 硬上限(bundled
const SUBREQ_BUDGET = 40; // 我們的目標上限(留 10 給 D_deprecated 等變動)
/** 精確估算「一個 envelope 打進 graph /triplets/ingest」會在 graph worker 內產生幾個 subrequest。 */
function estimateEnvelopeSubrequests(nTriplets, mNodes, dDeprecated = 0) {
return 7 + 4 * nTriplets + mNodes + dDeprecated;
}
// --- sha256content_hash 冪等鍵)---
// (sha256 移除:使用 code 沙箱注入的 curated builtin sha256)
// --- frontmatter 解析(極簡 YAML:只吃我們卡片用到的 tags / gloss / pipeline_candidate---
function parseFrontmatter(md) {
const m = md.match(/^---\n([\s\S]*?)\n---\n?/);
if (!m) return { data: {}, body: md };
const body = md.slice(m[0].length);
const data = {};
for (const line of m[1].split('\n')) {
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
if (!kv) continue;
const key = kv[1];
let val = kv[2].trim();
if (val.startsWith('[') && val.endsWith(']')) {
// inline list: [a, b, c]
data[key] = val.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
} else if (val === 'true' || val === 'false') {
data[key] = val === 'true';
} else {
data[key] = val;
}
}
return { data, body };
}
// --- 取某個 `## 標題` / `### 標題` 區塊的內文(到下一個同級或更高級標題為止)---
function sectionBody(md, heading) {
// heading 例:'## 實體'、'### 內文知識關係'
const level = heading.match(/^#+/)[0].length;
const lines = md.split('\n');
const out = [];
let inSec = false;
for (const line of lines) {
const h = line.match(/^(#+)\s+(.*)$/);
if (h) {
const thisLevel = h[1].length;
if (inSec) {
// 遇到同級或更高級標題 → 區塊結束
if (thisLevel <= level) break;
}
// 標題文字「開頭相符」即算命中(容忍標題後帶括號補述)
if (!inSec && thisLevel === level && line.replace(/^#+\s+/, '').startsWith(heading.replace(/^#+\s+/, ''))) {
inSec = true;
continue;
}
}
if (inSec) out.push(line);
}
return out.join('\n');
}
// --- 實體行解析:`- **正規名**(別名1/別名2)— 描述`(別名、描述皆選填)---
function parseEntities(md) {
const sec = sectionBody(md, '## 實體');
const entities = [];
for (const raw of sec.split('\n')) {
const line = raw.trim();
if (!line.startsWith('- ')) continue;
if (line.startsWith('- >') || line.startsWith('> ')) continue; // 跳過引言說明行
const m = line.match(/^- \*\*(.+?)\*\*(?:(.+?))?\s*(?:[—–\-]\s*(.*))?$/);
if (!m) continue;
const name = m[1].trim();
if (!name) continue;
const aliases = m[2]
? m[2].split(/[/、,]/).map((s) => s.trim()).filter((s) => s && s !== name)
: [];
const gloss = (m[3] || '').trim();
entities.push({ name, aliases, gloss });
}
return entities;
}
// --- typed-edge 行解析:`A >> 謂詞 >> B`(端點可為裸實體名或 [[wikilink]]---
function parseTypedEdges(sectionText) {
const edges = [];
for (const raw of (sectionText || '').split('\n')) {
const line = raw.trim();
if (!line.startsWith('- ')) continue;
const body = line.slice(2).trim();
if (body.startsWith('') || body.startsWith('(')) continue; // 「(暫無…)」占位行
const parts = body.split('>>');
if (parts.length !== 3) continue;
const subject = stripWikilink(parts[0].trim());
const predicate = parts[1].trim();
const object = stripWikilink(parts[2].trim());
if (!subject || !predicate || !object) continue;
edges.push({ subject, predicate, object });
}
return edges;
}
// [[notes/00-INDEX]] → notes/00-INDEX ;純字串則原樣回。
function stripWikilink(s) {
const m = s.match(/^\[\[(.+?)\]\]$/);
return m ? m[1].trim() : s;
}
// --- 抽所有 inline [[wikilink]](含 header 的 ← [[notes/00-INDEX]] 與內文)---
function extractInlineWikilinks(md) {
const out = [];
const re = /\[\[(.+?)\]\]/g;
let m;
while ((m = re.exec(md)) !== null) out.push(m[1].trim());
return out;
}
// --- 卡片 canonical id:以檔名(去副檔名)為準,對齊 `## 卡片關係` 用的 [[基名]] 慣例 ---
function cardCanonical(relPath) {
const base = relPath.split('/').pop().replace(/\.md$/, '');
return base;
}
/**
* 解析一張卡片 → { entry, nodes, triplets, meta }(尚未分段的原始產物)。
* relPath:卡片相對 repo 根路徑(如 system-dev/wiki/cards/notes/Xxx.md)。
* repo:如 'Leo/notes'。
*/
function parseCard(md, relPath, repo = 'Leo/notes') {
const { data: fm } = parseFrontmatter(md);
const canonical = cardCanonical(relPath);
const titleMatch = md.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : canonical;
// 1) 節點:## 實體 的正規名 + 別名 + gloss。
const entities = parseEntities(md);
// 2) 邊:內文知識關係(實體↔實體)+ 卡片關係(卡↔卡)+ inline wikilink(卡→卡 導覽/引用)。
const intraEdges = parseTypedEdges(sectionBody(md, '### 內文知識關係'))
.map((e) => ({ ...e, confidence: 1.0 }));
const cardEdges = parseTypedEdges(sectionBody(md, '### 卡片關係'))
.map((e) => ({ ...e, confidence: 1.0 }));
// inline wikilink(← [[notes/00-INDEX]] 等)→ 卡→卡「連結至」邊,去重、排除自環與已被 typed 邊覆蓋者。
const typedPairs = new Set(
[...cardEdges].map((e) => `${e.subject}\u0000${e.object}`),
);
const seenRef = new Set();
const refEdges = [];
for (const target of extractInlineWikilinks(md)) {
const t = stripWikilink(target);
if (t === canonical || t === title) continue; // 自環
if (typedPairs.has(`${canonical}\u0000${t}`)) continue; // 已有明確謂詞邊
const key = `${canonical}\u0000${t}`;
if (seenRef.has(key)) continue;
seenRef.add(key);
refEdges.push({ subject: canonical, predicate: '連結至', object: t, confidence: 0.5 });
}
const triplets = [...intraEdges, ...cardEdges, ...refEdges];
// 3) 節點清單:卡片本身(canonical,帶 frontmatter gloss+ 內文實體。
// 卡對卡邊指到的「別張卡」不在此補 node —— 那張卡自己被 ingest 時會補自己的 node。
const nodes = [];
const seenNode = new Set();
const pushNode = (n) => {
const k = n.name.toLowerCase();
if (!n.name || seenNode.has(k)) return;
seenNode.add(k);
nodes.push(n);
};
pushNode({ name: canonical, gloss: fm.gloss || '', aliases: title && title !== canonical ? [title] : [] });
for (const e of entities) pushNode({ name: e.name, gloss: e.gloss, aliases: e.aliases });
return {
entry: {
// base POST /entries(或 kbdb_upsert_block)用。metadata.embed=true → 語意可搜。
page_name: `wikicard:${repo}/${canonical}`, // idempotency key(穩定)
entry_type: 'wiki_card',
content: md, // 卡片全文逐字(embed 對象)
tags: Array.isArray(fm.tags) ? fm.tags : [],
metadata: {
embed: true, // ★ base embed 模組讀此旗標
source: `gitea:${repo}@${relPath}`,
content_hash: sha256(md),
kind: 'wiki_card',
repo,
canonical,
pipeline_candidate: fm.pipeline_candidate === true,
},
},
nodes,
triplets,
meta: { canonical, title, relPath, repo, contentHash: sha256(md) },
};
}
/**
* 把一張卡片的 (nodes, triplets) 打包成「一個或多個」ingest envelope
* 使每個 envelope 打進 graph 後的 subrequest 都 ≤ SUBREQ_BUDGET。
*
* 分段規則(對應頂層 SDD R4 / issue #8 第4點):
* - 單 envelope 夠塞(7+4N+M ≤ budget)→ 不分段,uri = 基 uri(無 anchor)。
* - 需分段 → 每段 uri = `<基uri>#seg{NN}`、anchor = `seg{NN}`。
* 每段是「獨立 source_uri」→ 各自獨立冪等,繞開 graph 的 per-source content_hash 整包 skip
* (否則同 uri 第 2 段起會被 line 65 的 content_hash 命中而整包跳過)。
* - 節點只放進「第一個引用到它的段」,跨段不重送(避免 graph persistNodes 重建 entity record)。
*/
function planEnvelopes(parsed, opts = {}) {
const budget = opts.budget ?? SUBREQ_BUDGET;
const repo = parsed.meta.repo;
const relPath = parsed.meta.relPath;
const baseUri = `gitea:${repo}@${relPath}`;
const contentHash = parsed.meta.contentHash;
const commit = opts.commit;
const extractor = {
model: opts.extractorModel ?? 'mechanical/km-wiki-card-parse@1',
tier: 'deep', // 人工精耕卡=deep(決定性、非淺萃)
extracted_at: Math.floor(Date.now() / 1000),
};
const nodeByName = new Map(parsed.nodes.map((n) => [n.name, n]));
// 貪婪打包:逐條 triplet 累進,段成本 = 7 + 4*(段內邊數) + (段內首見節點數)。
const segments = [];
let cur = null;
const startSeg = () => {
cur = { triplets: [], nodeNames: new Set() };
segments.push(cur);
};
const segCost = (seg, extraEdges = 0, extraNodes = 0) =>
estimateEnvelopeSubrequests(seg.triplets.length + extraEdges, seg.nodeNames.size + extraNodes);
startSeg();
for (const t of parsed.triplets) {
// 這條邊會新引入哪些節點(subject/object 命中 nodeByName 且本段尚未收)
const cand = [t.subject, t.object].filter(
(nm) => nodeByName.has(nm) && !cur.nodeNames.has(nm) && !anySegHas(segments, cur, nm),
);
// 放得下?(含新增這條邊 + 新引入節點)
if (cur.triplets.length > 0 && segCost(cur, 1, cand.length) > budget) {
startSeg();
}
cur.triplets.push(t);
for (const nm of [t.subject, t.object]) {
if (nodeByName.has(nm) && !anySegHas(segments, null, nm)) cur.nodeNames.add(nm);
}
}
const multi = segments.length > 1;
const envelopes = segments.map((seg, i) => {
const anchor = multi ? `seg${String(i + 1).padStart(2, '0')}` : undefined;
const uri = multi ? `${baseUri}#${anchor}` : baseUri;
const nodes = [...seg.nodeNames].map((nm) => {
const n = nodeByName.get(nm);
const out = { name: n.name };
if (n.gloss) out.gloss = n.gloss;
if (n.aliases && n.aliases.length) out.aliases = n.aliases;
out.embed = true;
return out;
});
const source = { uri, content_hash: contentHash };
if (anchor) source.anchor = anchor;
if (commit) source.commit = commit;
return {
source,
extractor,
nodes,
triplets: seg.triplets.map((t) => ({
subject: t.subject,
predicate: t.predicate,
object: t.object,
confidence: t.confidence ?? 1.0,
})),
_estSubrequests: estimateEnvelopeSubrequests(seg.triplets.length, seg.nodeNames.size),
};
});
// triplets≥1 是契約硬性;無邊的卡不產 envelope(仍會建 entry)。
return envelopes.filter((e) => e.triplets.length >= 1);
}
function anySegHas(segments, exclude, name) {
for (const s of segments) {
if (s === exclude) continue;
if (s.nodeNames.has(name)) return true;
}
return false;
}
/** 一張卡片 → 完整 ingest 計畫(entry + envelopes)。planCard = parseCard + planEnvelopes。 */
function planCard(md, relPath, repo = 'Leo/notes', opts = {}) {
const parsed = parseCard(md, relPath, repo);
const envelopes = planEnvelopes(parsed, opts);
return { entry: parsed.entry, envelopes, meta: parsed.meta, nodeCount: parsed.nodes.length, tripletCount: parsed.triplets.length };
}
// graph /triplets/ingest 是 strict schema,拒絕未知鍵。planEnvelopes 於 envelope 上掛的
// 診斷鍵 _estSubrequests 不在 ingest-candidate 契約內 → post 前剝除所有 _-前綴鍵,
// 讓 post_one_envelope 的 body_json={{envelope}} 為契約乾淨 payload。
// leo21c 實測:不剝除會回 422 unrecognized_keys "_estSubrequests"。)
const __plan = planCard(input.md, input.relPath, input.repo, input.opts || {});
__plan.envelopes = __plan.envelopes.map(function (e) {
const clean = {};
for (const k in e) { if (k.charAt(0) !== '_') clean[k] = e[k]; }
return clean;
});
return __plan;
input:
md: "{{fetch_card.data.body}}"
relPath: "{{pick_card.data.rel_path}}"
repo: "{{repo}}"
opts:
budget: 40 # subrequest 目標上限(留 10 給 D_deprecated),超過自動 anchor 分段
limits:
timeout_ms: 3000 # 純 CPU 解析;大卡也充裕
max_output_bytes: 4194304 # envelope 陣列可能較大(4 MiB
# ── 6) 卡片 → base entryserver 端冪等 upsertpage_name 當鍵 + content_hash skip-if-unchanged)。──
# 走 http_request 打 KBDB base 的 POST /entries/ingest(本分支新增;語義對稱 graph /triplets/ingest)。
# owner_id 走 querybody_json = parse_card 產的 entry 物件(含 metadata.content_hash / tags / embed:true)。
# 端點行為:無此 page_name → create;有且 content_hash 同 → skip(不重嵌);有且不同 → update + 重嵌。
upsert_entry:
component: http_request
method: POST
url: "https://arcrun-kbdb.uncle6-me.workers.dev/entries/ingest?owner_id=leo"
headers:
Content-Type: "application/json"
body_json: "{{parse_card.data.entry}}"
# ── 7) 進游標(同 /entries/ingest upsertcursor 專用 page_namecontent=pick_card 產的新游標 JSON 字串)。──
# 無 content_hash → 端點一律 update(游標每 tick 都變)。樂觀進游標:即使後續 envelope 失敗,
# 靠 graph 冪等 + 回捲重掃自癒,故游標僅為「掃到哪」的加速指標,非正確性關鍵。
save_cursor:
component: http_request
method: POST
url: "https://arcrun-kbdb.uncle6-me.workers.dev/entries/ingest?owner_id=leo"
headers:
Content-Type: "application/json"
body_json:
entry_type: "ingest_cursor"
page_name: "cursor:km_wiki_ingest_drain:Leo/notes"
content: "{{pick_card.data.cursor_content_json}}"
source: "gitea:Leo/notes"
metadata:
kind: "ingest_cursor"
embed: false
# ── 8) triplet envelope(可能多段)→ 逐段 POST graph /triplets/ingest。──
# graph 端 per-source(uri+content_hash) 冪等:同 hash 整包 no-op;分段各段 uri 不同 → 各自獨立冪等。
post_envelopes:
component: foreach_control
items: "{{parse_card.data.envelopes}}"
item_key: envelope
post_one_envelope:
component: http_request
method: POST
url: "https://kbdb-graph-plugin.uncle6-me.workers.dev/triplets/ingest?owner_id=leo"
headers:
Content-Type: "application/json"
X-Arcrun-API-Key: "leo"
body_json: "{{envelope}}" # envelope 已符合 ingest-candidate.json 契約(_-前綴診斷鍵已由 code 節點剝除)
# ═══════════════════════════════════════════════════════════════════════════
# 部署變數(皆已內嵌節點 data;此處僅列出以便 self-hosted fork 修改):
# repo=Leo/notes ref=main owner=leo cards_root=system-dev/wiki/cards
# kbdb_url = https://arcrun-kbdb.uncle6-me.workers.dev WORKER_SUBDOMAIN=uncle6-me,見 cypher wrangler
# graph_url = https://kbdb-graph-plugin.uncle6-me.workers.dev
# graph_api_key = leonamespace 字串,非機密)
# 機密:gitea_token → acr creds set gitea_token(節點以 {{credential.gitea_token}} 引用,部署時注入)。
# ⚠️ WORKER_SUBDOMAIN 須與實際部署帳號一致:官方=uncle6-me;若你的 kbdb/graph 部在別的
# workers.dev subdomain(例 leo21c),把上面 URL 一起改。舊 standalone drainer 誤用 leo21c。
# ═══════════════════════════════════════════════════════════════════════════