60f5f10ba5
n8n Code node 式逃生口:config 帶 inline JS、stdin 帶 input JSON、
stdout 回 {success,data}|{success:false,error,error_type}。
沙箱=QuickJS-wasm:user JS 跑在 QuickJS context,global 只有純 ECMAScript
內建 + 唯一 curated builtin sha256(純函式);碰不到網路/檔案/env/secret/
Worker 物件圖。資源上限:timeout(interrupt)/memory/stack/output/code size。
本輪=設計+PoC,未部署 leo21c。sandbox.mjs + test/ 為 Node/vitest 可跑實作
(12 測試全綠,含 card→envelope 與原模組 planCard 逐欄全等)。index.ts 為
Worker host 骨架、DESIGN.md 記錄機制/安全性質/生產路徑/設計岔路(A/B)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
328 lines
14 KiB
JavaScript
328 lines
14 KiB
JavaScript
// 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。
|
||
//
|
||
// 這支是「arcrun 自訂零件 km_wiki_card_parse」的參考實作(可移植成 Go/WASM 元件;
|
||
// 元件是 stdin→stdout JSON、無 fs/網路 syscall,本轉換剛好完全符合那個形狀)。
|
||
//
|
||
// 對齊契約:kbdb-ingest-plugin/contracts/ingest-candidate.json(envelope 形狀 / 禁止欄位)。
|
||
// 對齊頂層 SDD:卡片→entry(metadata.embed=true,走 base API)、wikilink→triplet(走 graph)。
|
||
//
|
||
// 鐵律:不碰儲存、不算向量、不建表。這支只「產出將寫入什麼」,實際 HTTP 由 workflow 打。
|
||
|
||
// (import removed: sandbox 提供注入的 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 removed: 使用沙箱注入的 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} |