c6932b0088
把知識庫 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
430 lines
20 KiB
YAML
430 lines
20 KiB
YAML
name: km_wiki_ingest_delta
|
||
description: >
|
||
穩態 delta ingest —— **cypher workflow,Gitea push webhook 觸發**(非 cron,非 standalone worker)。
|
||
只處理本次 commit 動到的 system-dev/wiki/cards/**/*.md(不重掃全庫),量小、天生不撞 subrequest 頂、
|
||
不需限速。同 drain 走 cypher binding(code 解析、http_request 打 kbdb/graph),零 Service Bindings。
|
||
觸發=Gitea repo Settings → Webhooks → 指向 cypher 的 named-webhook trigger URL:
|
||
https://cypher.arcrun.dev/webhooks/named/{namespace}/km_wiki_ingest_delta/trigger
|
||
(self-hosted:namespace 在 path 明碼;standard:改帶 X-Arcrun-API-Key header。見 DEPLOY.md)
|
||
⚠️ 這是 Gitea → Cloudflare(cypher),**非 GitHub Actions** → 不觸 GitHub flag 紅線(D4/D20)。
|
||
|
||
# 觸發 context = Gitea webhook payload({ commits:[{added,modified,removed}], ... })直接當初始 ctx。
|
||
# collect_changed(code) 從 payload 濾出動到的卡片 → 外層 FOREACH 逐卡跑
|
||
# fetch → parse → upsert entry → 內層 FOREACH 逐 envelope POST graph。全鏈冪等(未變檔自動 skip)。
|
||
|
||
flow:
|
||
- "collect_changed >> 對每個 card >> fetch_card_d" # code:payload → 變更卡路徑陣列
|
||
- "fetch_card_d >> ON_SUCCESS >> parse_card_d" # 抓卡片全文(Gitea raw)
|
||
- "parse_card_d >> ON_SUCCESS >> upsert_entry_d" # code:卡片 md → entry + envelope
|
||
- "upsert_entry_d >> ON_SUCCESS >> post_envelopes_d" # 卡片 → base entry(冪等 upsert)
|
||
- "post_envelopes_d >> 對每個 envelope >> post_one_envelope_d" # 逐段 POST graph /triplets/ingest
|
||
|
||
config:
|
||
# ── 1) 從 webhook payload 收集變更卡片(純函式 code)──
|
||
# output(→ .data):{ cards:[relPath...], removed:[...] }。removed 卡的 graph 清理未實作(另議)。
|
||
collect_changed:
|
||
component: code
|
||
code: |
|
||
const root = (input.cards_root || 'system-dev/wiki/cards').replace(/\/$/, '') + '/';
|
||
const isCard = (p) => typeof p === 'string' && p.startsWith(root) && p.endsWith('.md') && !((p.split('/').pop() || '').startsWith('00-INDEX'));
|
||
const changed = new Set(), removed = new Set();
|
||
for (const cm of (input.commits || [])) {
|
||
for (const p of [...(cm.added || []), ...(cm.modified || [])]) if (isCard(p)) changed.add(p);
|
||
for (const p of (cm.removed || [])) if (isCard(p)) removed.add(p);
|
||
}
|
||
return { cards: [...changed], removed: [...removed] };
|
||
input:
|
||
commits: "{{commits}}"
|
||
cards_root: "system-dev/wiki/cards"
|
||
limits:
|
||
timeout_ms: 2000
|
||
max_output_bytes: 1048576
|
||
|
||
# ── 2) 抓卡片全文(外層 FOREACH 注入的 {{card}} = 一條卡路徑)──
|
||
fetch_card_d:
|
||
component: http_request
|
||
method: GET
|
||
url: "https://git.uncle6.me/api/v1/repos/Leo/notes/raw/{{card}}?ref=main"
|
||
headers:
|
||
Authorization: "token {{credential.gitea_token}}"
|
||
|
||
parse_card_d:
|
||
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#10,sandbox inline JS)承載的解析邏輯本體。
|
||
// workflow.yaml 的 parse_card 節點把本檔的 planCard 邏輯內聯進 code 零件的 config
|
||
// (去 import/export、raw NUL 分隔符改 u0000 escape、改用 code 沙箱注入的 sha256);
|
||
// 不再鑄 domain 零件 km_wiki_card_parse(Arcrun#10 裁定:一次性解析走通用逃生口)。
|
||
// 本檔續留作「該內聯 JS 的權威來源 + 可單元測試的參考實作」(純函式、stdin→stdout JSON、無 fs/網路)。
|
||
//
|
||
// 對齊契約:kbdb-ingest-plugin/contracts/ingest-candidate.json(envelope 形狀 / 禁止欄位)。
|
||
// 對齊頂層 SDD:卡片→entry(metadata.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 > 50(CF 免費/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;
|
||
}
|
||
|
||
// --- sha256(content_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_d.data.body}}"
|
||
relPath: "{{card}}"
|
||
repo: "Leo/notes"
|
||
opts:
|
||
budget: 40
|
||
limits:
|
||
timeout_ms: 3000
|
||
max_output_bytes: 4194304
|
||
|
||
# ── 3) 卡片 → base entry(server 端冪等 upsert,同 drain)──
|
||
upsert_entry_d:
|
||
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_d.data.entry}}"
|
||
|
||
# ── 4) triplet envelope(可能多段)→ 內層 FOREACH 逐段 POST graph ──
|
||
post_envelopes_d:
|
||
component: foreach_control
|
||
items: "{{parse_card_d.data.envelopes}}"
|
||
item_key: envelope
|
||
|
||
post_one_envelope_d:
|
||
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}}"
|
||
|
||
# 部署變數同 workflow.yaml(drain)。差別:無 cron/游標;由 Gitea webhook 觸發 named-webhook trigger。
|