// 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 { createHash } from 'node:crypto'; // --- 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 分段」。 export const SUBREQ_CEILING = 50; // CF 單次 Worker invocation subrequest 硬上限(bundled) export const SUBREQ_BUDGET = 40; // 我們的目標上限(留 10 給 D_deprecated 等變動) /** 精確估算「一個 envelope 打進 graph /triplets/ingest」會在 graph worker 內產生幾個 subrequest。 */ export function estimateEnvelopeSubrequests(nTriplets, mNodes, dDeprecated = 0) { return 7 + 4 * nTriplets + mNodes + dDeprecated; } // --- sha256(content_hash 冪等鍵)--- export function sha256(text) { return createHash('sha256').update(text).digest('hex'); } // --- 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:以檔名(去副檔名)為準,對齊 `## 卡片關係` 用的 [[基名]] 慣例 --- export 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'。 */ export 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}${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}${t}`)) continue; // 已有明確謂詞邊 const key = `${canonical}${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)。 */ export 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。 */ export 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 }; }