feat(km-wiki-ingest): parse_card 改用通用 code 零件;刪 domain 零件 km_wiki_card_parse
Arcrun#10 裁定:一次性解析走通用逃生口,不再鑄 domain 零件。 - workflow.yaml:parse_card 由 component:km_wiki_card_parse -> component:code, config.code 內聯 card-to-envelope 的 planCard 邏輯(去 import/export、raw NUL 分隔符改 u0000 escape、改用 code 沙箱注入的 sha256);下游 refs 改 parse_card.data.*; 加 limits(timeout_ms/max_output_bytes)。已端到端驗證(YAML 解析->JS eval)與原 planCard 輸出逐欄全等(含 content_hash)。 - 刪 registry/examples/km-wiki-ingest/component-contract.yaml(km_wiki_card_parse 契約)。 - lib/card-to-envelope.mjs:header 改述為「code 節點內聯 JS 的權威來源 + 參考實作」, 邏輯不變(續為 inline JS 之單一真相源)。 - lib/dry-run.mjs / description.md:框架改述為 code-節點形態;部署清單更新為「部署通用 code 零件」。dry-run-evidence.json(3 entries/15 triplets/16 nodes)不變——解析輸出等價。 不部署、不寫 live。留分支可部署狀態。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
This commit is contained in:
@@ -1,65 +0,0 @@
|
||||
# 提案:新自訂 arcrun 零件 km_wiki_card_parse 的契約(Phase A 提案,尚未部署)。
|
||||
# 定位:機械把「已精耕 wiki 卡片」解析成 base entry + graph triplet envelope(無 LLM、無 fs/網路)。
|
||||
# 完全符合 arcrun 元件形狀(stdin_stdout_json、no_network_syscall、no_filesystem_syscall)。
|
||||
# 參考實作:lib/card-to-envelope.mjs(決定性純函式;部署時移植成 Go/WASM 或以 workerd JS 元件承載)。
|
||||
canonical_id: "km_wiki_card_parse"
|
||||
display_name: "KM Wiki 卡片機械解析"
|
||||
category: "transform"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true # 純轉換,不打網路
|
||||
no_filesystem_syscall: true # 卡片全文由上游 http_request 餵入,不讀檔
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [content, rel_path]
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
description: 卡片 Markdown 全文(上游 http_request 從 Gitea raw 抓)。
|
||||
rel_path:
|
||||
type: string
|
||||
description: 卡片相對 repo 根的路徑(如 system-dev/wiki/cards/notes/Xxx.md)。
|
||||
repo:
|
||||
type: string
|
||||
description: 來源 repo(如 Leo/notes),組 source.uri / page_name 用。預設 Leo/notes。
|
||||
budget:
|
||||
type: number
|
||||
description: 單 envelope subrequest 目標上限(預設 40,CF 硬頂 50)。超過則自動 anchor 分段。
|
||||
commit:
|
||||
type: string
|
||||
description: git commit sha(可追溯,寫進 source.commit)。選填。
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
entry:
|
||||
type: object
|
||||
description: 卡片→base entry。含 page_name(冪等鍵)、entry_type、content、tags、metadata(embed=true, source, content_hash)。
|
||||
envelopes:
|
||||
type: array
|
||||
description: >
|
||||
卡片→graph triplet envelope 陣列(已分段、已符合 ingest-candidate.json 契約)。
|
||||
每個 envelope 附 _estSubrequests;planner 保證每段 ≤ budget。無邊的卡回空陣列。
|
||||
meta:
|
||||
type: object
|
||||
description: canonical / title / content_hash 等追溯資訊。
|
||||
gherkin_tests:
|
||||
- scenario: "typed-edge 卡片"
|
||||
given: '{"content":"## 實體\n- **A** — a\n- **B** — b\n## 關聯\n### 內文知識關係\n- A >> 關係 >> B","rel_path":"system-dev/wiki/cards/notes/t.md"}'
|
||||
then_contains: '"subject":"A"'
|
||||
- scenario: "embed 打標"
|
||||
given: '{"content":"# t\n## 實體\n- **A** — a\n## 關聯\n### 內文知識關係\n- A >> r >> A","rel_path":"x/t.md"}'
|
||||
then_contains: '"embed":true'
|
||||
tags: [transform, wiki, ingest, mechanical, kbdb, primitive]
|
||||
description: |
|
||||
取代 kbdb-ingest-plugin 舊 raw→Haiku 路的機械版:讀「已精耕卡」的 ## 實體(節點)、
|
||||
## 關聯 的 typed-edge(A >> 關係 >> B)與 [[wikilink]],直接映射 entry + triplet,零 LLM、零 token。
|
||||
自動以 source_uri anchor 分段,保證每個 graph /triplets/ingest 呼叫壓在 CF subrequest 上限下。
|
||||
@@ -13,15 +13,15 @@
|
||||
|
||||
## 形式選擇與理由(給總管)
|
||||
|
||||
**形式 = Arcrun workflow(YAML 編排)+ 1 個新自訂零件 `km_wiki_card_parse`(機械解析)+ 現成零件(`cron` / `http_request` / `foreach_control` / `kbdb_upsert_block`)。**
|
||||
**形式 = Arcrun workflow(YAML 編排)+ 通用 `code` 零件(sandbox inline JS,Arcrun#10)承載卡片→envelope 解析 + 現成零件(`cron` / `http_request` / `foreach_control` / `kbdb_upsert_block`)。** 不再鑄 domain 零件 `km_wiki_card_parse`(Arcrun#10 裁定:一次性解析走通用逃生口)。
|
||||
|
||||
理由:
|
||||
|
||||
1. **編排本來就是 arcrun 的主場**:cron 限速 drain、Gitea webhook 只吃 delta、foreach 小批、冪等 upsert——這些跟現成零件 1:1 對得上,且 leo 要「Arcrun workflow 慢慢做」、arcrun 哲學禁一次性腳本。
|
||||
2. **arcrun 唯一缺的是「卡片 → envelope」的解析**。那是一段**決定性純轉換**(無 LLM、無網路、無檔案)——剛好是自訂零件的理想形狀(`stdin_stdout_json` + `no_network_syscall` + `no_filesystem_syscall`)。把它做成零件(而非在 YAML 裡塞 `string_ops` 正則)讓 workflow 可讀、解析可單元測試,貼合 arcrun「零件」複用哲學。
|
||||
3. **小批是結構性的,不是靠祈禱**:一卡一 tick,每卡在 graph worker 的 fan-out ≈ `7+4N+M` subrequest(notes 卡 N≈4/M≈5 → est 28~33,穩壓 CF 50 頂下);解析零件會**預先把超大卡以 `source_uri` anchor 分段**,任何單一 graph 呼叫都不破頂。
|
||||
2. **arcrun 唯一缺的是「卡片 → envelope」的解析**。那是一段**決定性純轉換**(無 LLM、無網路、無檔案)——正好是 `code` 零件 sandbox 的理想形狀(`stdin_stdout_json` + `no_network_syscall` + `no_filesystem_syscall`)。用通用 `code` 節點內聯這段 JS(而非鑄 domain 零件、也非在 YAML 裡塞 `string_ops` 正則):workflow 可讀、解析可單元測試、且 registry 不因一次性邏輯增生 domain 零件。
|
||||
3. **小批是結構性的,不是靠祈禱**:一卡一 tick,每卡在 graph worker 的 fan-out ≈ `7+4N+M` subrequest(notes 卡 N≈4/M≈5 → est 28~33,穩壓 CF 50 頂下);`code` 節點內聯解析會**預先把超大卡以 `source_uri` anchor 分段**,任何單一 graph 呼叫都不破頂。
|
||||
|
||||
**Phase A 交付**:純解析+打包核心(`lib/card-to-envelope.mjs`,現在就能跑)+乾跑驗證器(`lib/dry-run.mjs`,印出「將寫入什麼」)+本 workflow.yaml +零件契約(部署被閘控,故 live 接線是「設計而非執行」)。
|
||||
**Phase A 交付**:純解析+打包核心(`lib/card-to-envelope.mjs`,現在就能跑,= `code` 節點內聯 JS 的權威來源)+乾跑驗證器(`lib/dry-run.mjs`,印出「將寫入什麼」)+本 workflow.yaml(parse_card = `code` 節點)。解析零件=通用 `code`(Arcrun#10 分支,已就緒待部署);本 example 不再自帶 domain 零件契約。部署被閘控,故 live 接線是「設計而非執行」。
|
||||
|
||||
## 診斷小結:fan-out 精確來源 + 小批為何解得掉
|
||||
|
||||
@@ -66,7 +66,7 @@ self-test 合成超大卡(不分段 est=114 會炸)→ 自動分 4 段、每
|
||||
|
||||
## 待 live 部署 + 寫入(總管過 leo 閘用)
|
||||
|
||||
1. **部署新零件 `km_wiki_card_parse`**:移植 `lib/card-to-envelope.mjs` → arcrun 元件(Go/WASM 或 workerd JS 承載)→ `acr` 註冊。
|
||||
1. **部署通用 `code` 零件**(Arcrun#10 分支 `feat/issue-10-code-component`,已就緒):`cd registry/components/code && npm install && npx wrangler deploy`(→ `code.arcrun.dev`)+ `register-component.sh code`。本 workflow 的 parse_card 以 `component: code` 引用它,解析 JS 已內聯在 workflow.yaml(= `lib/card-to-envelope.mjs` 邏輯)。不再部署 domain 零件 `km_wiki_card_parse`。
|
||||
2. **部署 workflow**:`km_wiki_ingest_drain`(cron drain);`wrangler` 直推 leo21c(**禁 `acr update`**——codeload 綁 GitHub 假綠,Arcrun#4)。
|
||||
3. **注入環境變數**(不放 repo):`repo=Leo/notes ref=main gitea_token kbdb_url=https://arcrun-kbdb.leo21c.workers.dev kbdb_api_key graph_url graph_api_key=leo`。`CLOUDFLARE_ACCOUNT_ID=leo21c`(別讓官方 58309b 污染)。
|
||||
4. **entry 寫入路徑確認**:若 `kbdb_upsert_block` 尚不透傳 `metadata_json`(需 `embed:true`/`content_hash`),entry 改用 `http_request` 直打 base `POST/PATCH /entries` 帶 `body_json.metadata_json`。
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
// km-wiki-ingest 乾跑(dry-run)驗證器 — 不寫 live、不部署。
|
||||
// -------------------------------------------------------------
|
||||
// 註:解析在 live 由 workflow.yaml 的 parse_card = 通用 `code` 零件(sandbox inline JS)承載;
|
||||
// 本驗證器直接 import card-to-envelope.mjs(=該 code 節點內聯 JS 的權威來源)跑同一份邏輯,
|
||||
// 故 dry-run 的 envelope 結果與 code 節點在 live 的輸出等價(Arcrun#10 已單測證明逐欄全等)。
|
||||
// 對某 repo 的 system-dev/wiki/cards/**/*.md 跑機械解析 + envelope 打包,
|
||||
// 輸出「將寫入什麼」:① entry 清單(page_name / entry_type / metadata.embed / content_hash)
|
||||
// ② triplet envelope 清單(每段的 nodes / triplets / source.uri+anchor)
|
||||
|
||||
@@ -3,13 +3,14 @@ description: >
|
||||
Phase 0 限速 drain:cron 每 tick 只處理「一張卡」→ 機械解析成 entry + triplet envelope
|
||||
→ 冪等寫 KBDB(base entry / graph triplet)。反覆跑直到全庫 drain 完。
|
||||
來源=repo 的 system-dev/wiki/cards/**/*.md(人工精耕卡,非裸筆記,無 LLM)。
|
||||
解析由通用 code 零件(sandbox inline JS)承載,不再鑄 domain 零件(Arcrun#10 裁定)。
|
||||
穩態(Gitea push webhook 只處理 delta)見檔尾 §穩態變體。
|
||||
|
||||
# ── 為什麼「一 tick 一卡」=根治 07_01 的 Too many subrequests ──
|
||||
# graph worker 處理一次 POST /triplets/ingest 的 subrequest = 7 + 4*N_triplets + M_nodes + D_deprecated。
|
||||
# 07_01 炸點:單一 envelope 吞整檔 N=11,M=10 → 61 > 50(CF bundled 上限)→ 半殘。
|
||||
# 對策:① 一卡一 tick(天然小批,notes 卡 ~N4/M5 → est 28~33,穩壓 50 下)
|
||||
# ② km_wiki_card_parse 會自動把超大卡以 source_uri anchor 分段(每段獨立冪等)。
|
||||
# ② code 節點的內聯解析會自動把超大卡以 source_uri anchor 分段(每段獨立冪等)。
|
||||
# ⟹ 任何單一 graph 呼叫都不會再破頂。
|
||||
|
||||
flow:
|
||||
@@ -47,15 +48,357 @@ config:
|
||||
headers:
|
||||
Authorization: "token {{gitea_token}}"
|
||||
|
||||
# 4) ★ 機械解析(新自訂零件,無 LLM、無 fs/網路,stdin→stdout JSON)。
|
||||
# input:卡片全文 + 相對路徑 + repo;output:{ entry, envelopes[] }(envelope 已分段、已估 subrequest)。
|
||||
# 參考實作見 lib/card-to-envelope.mjs(可移植成 Go/WASM 元件)。
|
||||
# 4) ★ 機械解析 —— 通用 code 零件(sandbox inline JS,無 LLM、無 fs/網路,stdin→stdout JSON)。
|
||||
# Arcrun#10 裁定:一次性解析邏輯走通用逃生口,不再鑄 domain 零件 km_wiki_card_parse。
|
||||
# 下面 code: 內聯的即 lib/card-to-envelope.mjs 的 planCard 邏輯(去 import/export、
|
||||
# raw NUL 分隔符改 \u0000 escape、改用 code 沙箱注入的 curated builtin sha256;
|
||||
# 已單測證明與原模組輸出逐欄全等)。
|
||||
# input:卡片全文 md + 相對路徑 relPath + repo + opts.budget(subrequest 目標上限)。
|
||||
# output:{ success:true, data:{ entry, envelopes[], meta, nodeCount, tripletCount } }
|
||||
# —— envelope 已分段、已估 subrequest。故下游改引用 parse_card.data.*。
|
||||
parse_card:
|
||||
component: km_wiki_card_parse
|
||||
content: "{{fetch_card.data.body}}"
|
||||
rel_path: "{{pick_next_card.next.rel_path}}"
|
||||
repo: "{{repo}}"
|
||||
budget: 40 # subrequest 目標上限(留 10 給 D_deprecated)
|
||||
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 };
|
||||
}
|
||||
|
||||
return planCard(input.md, input.relPath, input.repo, input.opts || {});
|
||||
|
||||
input:
|
||||
md: "{{fetch_card.data.body}}"
|
||||
relPath: "{{pick_next_card.next.rel_path}}"
|
||||
repo: "{{repo}}"
|
||||
opts:
|
||||
budget: 40 # subrequest 目標上限(留 10 給 D_deprecated),超過自動 anchor 分段
|
||||
limits:
|
||||
timeout_ms: 3000 # 純 CPU 解析;大卡也充裕
|
||||
max_output_bytes: 4194304 # envelope 陣列可能較大(4 MiB)
|
||||
|
||||
# 5) 卡片 → base entry,冪等 upsert(page_name 當鍵;找到 PATCH、沒有 POST)。
|
||||
# metadata.embed=true → base embed 模組會補嵌 → 語意可搜。
|
||||
@@ -63,11 +406,11 @@ config:
|
||||
component: kbdb_upsert_block
|
||||
api_key: "{{kbdb_api_key}}"
|
||||
kbdb_url: "{{kbdb_url}}"
|
||||
page_name: "{{parse_card.entry.page_name}}"
|
||||
type: "{{parse_card.entry.entry_type}}"
|
||||
content: "{{parse_card.entry.content}}"
|
||||
source: "{{parse_card.entry.metadata.source}}"
|
||||
tags_json: "{{parse_card.entry.tags_json}}"
|
||||
page_name: "{{parse_card.data.entry.page_name}}"
|
||||
type: "{{parse_card.data.entry.entry_type}}"
|
||||
content: "{{parse_card.data.entry.content}}"
|
||||
source: "{{parse_card.data.entry.metadata.source}}"
|
||||
tags_json: "{{parse_card.data.entry.tags_json}}"
|
||||
# ⚠️ metadata.embed=true / content_hash 需經 base /entries 帶 metadata_json 落地;
|
||||
# 若 kbdb_upsert_block 尚未透傳 metadata_json,改用 http_request 直打 base POST/PATCH /entries
|
||||
# 帶 body_json.metadata_json(見 description.md §entry 冪等)。
|
||||
@@ -76,7 +419,7 @@ config:
|
||||
# graph 端 per-source(uri+content_hash) 冪等:同 hash 整包 no-op;分段各段 uri 不同 → 各自獨立冪等。
|
||||
post_envelopes:
|
||||
component: foreach_control
|
||||
items: "{{parse_card.envelopes}}"
|
||||
items: "{{parse_card.data.envelopes}}"
|
||||
item_key: envelope
|
||||
|
||||
post_one_envelope:
|
||||
|
||||
Reference in New Issue
Block a user