From cc494a250ef504b0dc30b5b1dd47757a6da0c6a7 Mon Sep 17 00:00:00 2001 From: Arcrun CC Date: Mon, 6 Jul 2026 03:52:50 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(ingest):=20=E6=A9=9F=E6=A2=B0=E5=BC=8F?= =?UTF-8?q?=20wiki=20=E5=8D=A1=E7=89=87=E2=86=92KBDB=20ingest=EF=BC=88Arcr?= =?UTF-8?q?un#8=20Phase=20A=EF=BC=8Cdry-run=20=E9=A9=97=E8=AD=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新 ingest 模型(無 LLM,取代舊 raw→Haiku 路): - 來源 = system-dev/wiki/cards/**/*.md(精耕卡)+ ## 實體/## 關聯 typed-edge + [[wikilink]] - 卡片→base entry(metadata.embed=true);typed-edge/wikilink→graph triplet - 純機械決定性,零 token 形式 = Arcrun workflow(cron drain + Gitea webhook delta)+ 新自訂零件 km_wiki_card_parse 不撞頂設計:一卡一 tick + 超大卡以 source_uri anchor 自動分段; graph fan-out 精確 = 7+4N+M+D,dry-run 對 notes 三卡上限 33 subrequest(<50)。 Phase A:不部署、不寫 live。含純核心 + dry-run 證據(3 entries/15 triplets/16 nodes)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o --- .../km-wiki-ingest/component-contract.yaml | 65 +++++++ .../examples/km-wiki-ingest/description.md | 74 ++++++++ .../km-wiki-ingest/dry-run-evidence.json | 176 +++++++++++++++++ .../km-wiki-ingest/lib/card-to-envelope.mjs | Bin 0 -> 14058 bytes .../examples/km-wiki-ingest/lib/dry-run.mjs | 178 ++++++++++++++++++ registry/examples/km-wiki-ingest/tags.json | 1 + .../examples/km-wiki-ingest/workflow.yaml | 105 +++++++++++ 7 files changed, 599 insertions(+) create mode 100644 registry/examples/km-wiki-ingest/component-contract.yaml create mode 100644 registry/examples/km-wiki-ingest/description.md create mode 100644 registry/examples/km-wiki-ingest/dry-run-evidence.json create mode 100644 registry/examples/km-wiki-ingest/lib/card-to-envelope.mjs create mode 100644 registry/examples/km-wiki-ingest/lib/dry-run.mjs create mode 100644 registry/examples/km-wiki-ingest/tags.json create mode 100644 registry/examples/km-wiki-ingest/workflow.yaml diff --git a/registry/examples/km-wiki-ingest/component-contract.yaml b/registry/examples/km-wiki-ingest/component-contract.yaml new file mode 100644 index 0000000..7732f7f --- /dev/null +++ b/registry/examples/km-wiki-ingest/component-contract.yaml @@ -0,0 +1,65 @@ +# 提案:新自訂 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 上限下。 diff --git a/registry/examples/km-wiki-ingest/description.md b/registry/examples/km-wiki-ingest/description.md new file mode 100644 index 0000000..369e9ad --- /dev/null +++ b/registry/examples/km-wiki-ingest/description.md @@ -0,0 +1,74 @@ +# km-wiki-ingest — 機械式 wiki 卡片 → KBDB ingest(Arcrun#8 / 頂層 SDD T2–T4) + +> Phase A 產物:機械 ingest 邏輯 + 乾跑證據 + workflow 設計。**不部署、不寫 live KBDB。** + +## 解決什麼問題 + +把各 repo 的 `system-dev/wiki/cards/**/*.md`(人工精耕卡)**機械地**(無 LLM)灌進 leo21c KBDB: + +- **卡片 → base entry**(`metadata.embed=true`,供語意搜尋)。 +- **`## 實體` → graph node**;**`## 關聯` 的 typed-edge(`A >> 關係 >> B`)與 `[[wikilink]]` → graph triplet**。 + +取代舊 `kbdb-ingest-plugin/scripts/ingest-cli.mjs` 的 `raw → Haiku → 三元組` 路:新路純解析卡片內既有結構,**決定性、零 token、零幻覺**。 + +## 形式選擇與理由(給總管) + +**形式 = Arcrun workflow(YAML 編排)+ 1 個新自訂零件 `km_wiki_card_parse`(機械解析)+ 現成零件(`cron` / `http_request` / `foreach_control` / `kbdb_upsert_block`)。** + +理由: + +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 呼叫都不破頂。 + +**Phase A 交付**:純解析+打包核心(`lib/card-to-envelope.mjs`,現在就能跑)+乾跑驗證器(`lib/dry-run.mjs`,印出「將寫入什麼」)+本 workflow.yaml +零件契約(部署被閘控,故 live 接線是「設計而非執行」)。 + +## 診斷小結:fan-out 精確來源 + 小批為何解得掉 + +讀 `kbdb-graph-plugin` 現役寫入路徑(`triplet-ingest.ts` / `triplet-crud.ts` / `templates.ts` / `kbdb-client.ts`)逐行拆帳: + +``` +POST /triplets/ingest(graph worker 單次 invocation)對 base 的 subrequest: + ensurePluginTemplates(3) # 頂層一次 + + listRecordsByTemplate(1) # 抓同 source 現存 active(冪等分組) + + Σ_triplet [ createTriplet → ensurePluginTemplates(3) + createRecord(1) ] # ★ 每條邊重跑 ensure! + + persistNodes [ ensurePluginTemplates(3) + Σ_node createRecord(1) ] + + Σ_deprecated updateRecord(1) + = 7 + 4*N_triplets + M_nodes + D_deprecated +``` + +- **精確炸點還原**:07_01 單一 envelope 吞 `N=11, M=10, D=0` → `7+44+10 = 61 > 50` → 破頂半殘。**放大器=`createTriplet` 內每條邊都重呼 `ensurePluginTemplates`(3 個 GET)**,佔了 33/61。 +- **小批為何解得掉**:把「整檔一 envelope」改成「一卡一 envelope、必要時再 anchor 分段」,把 `N` 壓到讓 `7+4N+M ≤ 40`。notes 三卡實測 est 上限 = 33,全綠。超大卡(自測 20 邊/22 節點=114)→ 自動分 4 段,每段 ≤ 38。 +- **附帶建議(非本 Phase 必改)**:graph 端把 `createTriplet`/`persistNodes` 內重複的 `ensurePluginTemplates` 提到 ingest 入口只跑一次,可把每 envelope 省下 `3*(N+1)` 個 subrequest(單卡 est 33→約 18),批量還能更大。此為 graph-plugin 的可選優化,記此存查。 + +## 冪等設計 + +| 對象 | 冪等鍵 | 行為 | +|---|---|---| +| **entry** | `page_name`(穩定:`wikicard:/`)+ `metadata.content_hash` | 找到同 page_name:hash 相同 → skip;不同 → PATCH content(觸發重嵌)。沒有 → POST 新建。 | +| **triplet envelope** | `source.uri` + `source.content_hash` | graph 現役 per-source 冪等:同 hash 整包 no-op(`triplet-ingest.ts:65`)。 | +| **分段** | 各段 `source.uri = <基uri>#segNN` | 各段獨立 uri → 各自獨立冪等,**繞開 per-source content_hash 整包 skip**(否則同 uri 第 2 段起會被判定「已落地」而整包跳過)。節點只放進「首次引用它的段」,跨段不重送(避免 graph 重建 entity)。 | + +## 觸發(兩階段,對齊 SDD R3) + +- **Phase 0(一次性 backfill)**:`cron */2` 每 tick drain 一張卡(限速慢推),反覆跑到全庫清空。冪等 → 可續傳、重跑零寫入。 +- **穩態(日常增量)**:**Gitea push webhook → arcrun workflow**,只吃 `commits[].{added,modified}` 中的 `system-dev/wiki/cards/**/*.md`。⚠️ Gitea → Cloudflare(arcrun),**非 GitHub Actions**,不觸 GitHub flag 紅線(D4/D20)。量小、不撞頂、不限速。 + +## 乾跑證據(Phase A,不寫 live) + +``` +node lib/dry-run.mjs --repo-path --repo Leo/notes --self-test +``` + +對 `Leo/notes` 的 3 張卡實測:3 entries(embed=true)+ 3 envelopes、15 triplets、16 nodes; +**單次 graph 呼叫 subrequest 上限 = 33(< 50),無任一 envelope 破頂**。 +self-test 合成超大卡(不分段 est=114 會炸)→ 自動分 4 段、每段 ≤ 38,全綠。 + +## 待 live 部署 + 寫入(總管過 leo 閘用) + +1. **部署新零件 `km_wiki_card_parse`**:移植 `lib/card-to-envelope.mjs` → arcrun 元件(Go/WASM 或 workerd JS 承載)→ `acr` 註冊。 +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`。 +5. **預期寫入量(Leo/notes 現況 3 卡)**:3 entries + 15 triplets + 16 node records(去重後更少);分 3 次 graph 呼叫(每次 ≤ 33 subrequest)+ 3 次 entry upsert。全庫鋪開時照 cron 一卡一 tick 慢推。 +6. **驗收**:ingest 後 `GET /embed/backfill/status` 應見 pending 上升→drain 後歸零、embedded 增加;三模式(關鍵字/語意/圖)curl 驗。 diff --git a/registry/examples/km-wiki-ingest/dry-run-evidence.json b/registry/examples/km-wiki-ingest/dry-run-evidence.json new file mode 100644 index 0000000..ae2a62d --- /dev/null +++ b/registry/examples/km-wiki-ingest/dry-run-evidence.json @@ -0,0 +1,176 @@ +{ + "repo": "Leo/notes", + "commit": "4b9a53c1c99d596c23b1b449fd79728610f6995b", + "budget": 40, + "ceiling": 50, + "cards": [ + { + "relPath": "system-dev/wiki/cards/notes/Gitea當後端編輯器全CF化網站構想.md", + "canonical": "Gitea當後端編輯器全CF化網站構想", + "entry": { + "page_name": "wikicard:Leo/notes/Gitea當後端編輯器全CF化網站構想", + "entry_type": "wiki_card", + "metadata.embed": true, + "content_hash": "64b402952fe0…", + "content_bytes": 2324, + "tags": [ + "系統設計", + "工具教學" + ] + }, + "envelopeCount": 1, + "envelopes": [ + { + "source.uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/Gitea當後端編輯器全CF化網站構想.md", + "source.anchor": null, + "nodes": 6, + "triplets": 5, + "est_subrequests": 33, + "under_ceiling": true, + "sample_triplets": [ + "Gitea >> 類比於 >> WordPress (1)", + "Gitea >> 充當後端供稿給 >> Cloudflare Pages (1)", + "Quartz >> 目前負責轉譯給 >> Cloudflare Pages (1)", + "Cloudflare Artifacts >> 若提供 git 倉庫則可取代 >> Gitea (1)" + ], + "sample_nodes": [ + "Gitea — 可自架的 git 平台,編輯體驗近似 WordPress 後…", + "WordPress — 常見的內容管理後端,作為 Gitea 編輯體驗的類比對象。…", + "Cloudflare Pages — Cloudflare 的靜態站前端託管。…", + "Quartz — 目前把筆記轉成網站前端的工具。…" + ] + } + ] + }, + { + "relPath": "system-dev/wiki/cards/notes/Prompt能力即拆解自己邏輯的能力.md", + "canonical": "Prompt能力即拆解自己邏輯的能力", + "entry": { + "page_name": "wikicard:Leo/notes/Prompt能力即拆解自己邏輯的能力", + "entry_type": "wiki_card", + "metadata.embed": true, + "content_hash": "63296a663227…", + "content_bytes": 2109, + "tags": [ + "AI協作", + "工具教學", + "觀點主張" + ] + }, + "envelopeCount": 1, + "envelopes": [ + { + "source.uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/Prompt能力即拆解自己邏輯的能力.md", + "source.anchor": null, + "nodes": 5, + "triplets": 5, + "est_subrequests": 32, + "under_ceiling": true, + "sample_triplets": [ + "Prompt 能力 >> 本質上等於 >> 邏輯拆解能力 (1)", + "邏輯拆解能力 >> 產出 >> pseudo code (1)", + "pseudo code >> 足以教會 >> AI (1)", + "Prompt能力即拆解自己邏輯的能力 >> 呼應 >> 程式化邏輯可圖解任何主題不限AI (1)" + ], + "sample_nodes": [ + "Prompt 能力 — 把腦中意圖轉成能指揮 AI 的指令的能力。…", + "邏輯拆解能力 — 把腦中隱性流程外顯成可陳述步驟的能力。…", + "pseudo code — 用類程式的步驟描述邏輯、尚未綁定特定語法的表達。…", + "AI — 需被人以指令/範例指揮才產出的生成模型。…" + ] + } + ] + }, + { + "relPath": "system-dev/wiki/cards/notes/程式化邏輯可圖解任何主題不限AI.md", + "canonical": "程式化邏輯可圖解任何主題不限AI", + "entry": { + "page_name": "wikicard:Leo/notes/程式化邏輯可圖解任何主題不限AI", + "entry_type": "wiki_card", + "metadata.embed": true, + "content_hash": "a9dbf5fc0f9a…", + "content_bytes": 2577, + "tags": [ + "工具教學", + "觀點主張", + "系統設計" + ] + }, + "envelopeCount": 1, + "envelopes": [ + { + "source.uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/程式化邏輯可圖解任何主題不限AI.md", + "source.anchor": null, + "nodes": 5, + "triplets": 5, + "est_subrequests": 32, + "under_ceiling": true, + "sample_triplets": [ + "程式化邏輯 >> 可圖解 >> 亞洲金融風暴 (1)", + "流程圖解 >> 奠基於 >> 程式化邏輯 (1)", + "系統動力學 >> 類同於 >> 流程圖解 (1)", + "程式化邏輯可圖解任何主題不限AI >> 呼應 >> Prompt能力即拆解自己邏輯的能力 (1)" + ], + "sample_nodes": [ + "程式化邏輯 — 以程式的因果鏈結構來表述任一領域的邏輯。…", + "亞洲金融風暴 — 講者小 Lin 用長邏輯鏈敘述的金融事件案例。…", + "流程圖解 — 用 n8n 這類流程工具把邏輯視覺化講解的方法。…", + "系統動力學 — 以存量流量與回饋環圖解因果的建模工具。…" + ] + } + ] + } + ], + "totals": { + "cards": 3, + "entries_to_upsert": 3, + "triplet_envelopes": 3, + "total_triplets": 15, + "total_nodes": 16, + "max_est_subrequests_single_call": 33, + "ceiling": 50, + "budget": 40, + "any_envelope_over_ceiling": 0 + }, + "self_test": { + "note": "合成 20 邊 / 22 節點 的超大卡", + "if_single_envelope_est_subrequests": 114, + "would_crash_single": true, + "segmented_into": 4, + "per_segment": [ + { + "uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg01", + "anchor": "seg01", + "triplets": 6, + "nodes": 7, + "est_subrequests": 38, + "under_ceiling": true + }, + { + "uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg02", + "anchor": "seg02", + "triplets": 6, + "nodes": 6, + "est_subrequests": 37, + "under_ceiling": true + }, + { + "uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg03", + "anchor": "seg03", + "triplets": 6, + "nodes": 6, + "est_subrequests": 37, + "under_ceiling": true + }, + { + "uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg04", + "anchor": "seg04", + "triplets": 3, + "nodes": 3, + "est_subrequests": 22, + "under_ceiling": true + } + ], + "all_segments_under_ceiling": true + } +} diff --git a/registry/examples/km-wiki-ingest/lib/card-to-envelope.mjs b/registry/examples/km-wiki-ingest/lib/card-to-envelope.mjs new file mode 100644 index 0000000000000000000000000000000000000000..46ed7ed70015d4d8fd8aa8b55592adc3288df0cc GIT binary patch literal 14058 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 XQ%y)OV};fx&g&dDPFX8aTqF2DT1I-g literal 0 HcmV?d00001 diff --git a/registry/examples/km-wiki-ingest/lib/dry-run.mjs b/registry/examples/km-wiki-ingest/lib/dry-run.mjs new file mode 100644 index 0000000..abfed45 --- /dev/null +++ b/registry/examples/km-wiki-ingest/lib/dry-run.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// km-wiki-ingest 乾跑(dry-run)驗證器 — 不寫 live、不部署。 +// ------------------------------------------------------------- +// 對某 repo 的 system-dev/wiki/cards/**/*.md 跑機械解析 + envelope 打包, +// 輸出「將寫入什麼」:① entry 清單(page_name / entry_type / metadata.embed / content_hash) +// ② triplet envelope 清單(每段的 nodes / triplets / source.uri+anchor) +// ③ 每個 graph /triplets/ingest 呼叫的 subrequest 估算(證明壓在 CF 上限下) +// ④ 冪等鍵設計(entry=page_name+content_hash;triplet=source.uri+content_hash)。 +// +// 用法:node dry-run.mjs --repo-path [--repo Leo/notes] [--budget 40] [--json] [--full] +// --self-test 額外跑「合成超大卡」證明分段生效。 + +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { planCard, estimateEnvelopeSubrequests, SUBREQ_CEILING, SUBREQ_BUDGET } from './card-to-envelope.mjs'; + +function parseArgs(argv) { + const out = { repo: 'Leo/notes', budget: SUBREQ_BUDGET }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--repo-path') out.repoPath = argv[++i]; + else if (a === '--repo') out.repo = argv[++i]; + else if (a === '--budget') out.budget = Number(argv[++i]); + else if (a === '--json') out.json = true; + else if (a === '--full') out.full = true; + else if (a === '--self-test') out.selfTest = true; + } + return out; +} + +function walkCards(dir) { + const out = []; + if (!existsSync(dir)) return out; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...walkCards(p)); + else if (e.name.endsWith('.md') && e.name !== '.gitkeep' && !e.name.startsWith('00-INDEX')) out.push(p); + } + return out; +} + +function gitCommit(repoPath) { + try { + return execFileSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + } catch { return undefined; } +} + +const args = parseArgs(process.argv.slice(2)); +if (!args.repoPath) { + console.error('用法: node dry-run.mjs --repo-path [--repo Leo/notes] [--budget 40] [--json] [--full] [--self-test]'); + process.exit(1); +} + +const cardsRoot = path.join(args.repoPath, 'system-dev', 'wiki', 'cards'); +const cardPaths = walkCards(cardsRoot); +const commit = gitCommit(args.repoPath); + +const report = { repo: args.repo, commit, budget: args.budget, ceiling: SUBREQ_CEILING, cards: [], totals: {} }; +let totEntries = 0, totEnvelopes = 0, totTriplets = 0, totNodes = 0, maxSub = 0, over = 0; + +for (const p of cardPaths) { + const rel = path.relative(args.repoPath, p); + const md = readFileSync(p, 'utf8'); + const plan = planCard(md, rel, args.repo, { budget: args.budget, commit }); + totEntries++; + totEnvelopes += plan.envelopes.length; + const cardTriplets = plan.envelopes.reduce((s, e) => s + e.triplets.length, 0); + const cardNodes = plan.envelopes.reduce((s, e) => s + e.nodes.length, 0); + totTriplets += cardTriplets; + totNodes += cardNodes; + for (const e of plan.envelopes) { + maxSub = Math.max(maxSub, e._estSubrequests); + if (e._estSubrequests > SUBREQ_CEILING) over++; + } + report.cards.push({ + relPath: rel, + canonical: plan.meta.canonical, + entry: { + page_name: plan.entry.page_name, + entry_type: plan.entry.entry_type, + 'metadata.embed': plan.entry.metadata.embed, + content_hash: plan.entry.metadata.content_hash.slice(0, 12) + '…', + content_bytes: Buffer.byteLength(plan.entry.content, 'utf8'), + tags: plan.entry.tags, + }, + envelopeCount: plan.envelopes.length, + envelopes: plan.envelopes.map((e) => ({ + 'source.uri': e.source.uri, + 'source.anchor': e.source.anchor ?? null, + nodes: e.nodes.length, + triplets: e.triplets.length, + est_subrequests: e._estSubrequests, + under_ceiling: e._estSubrequests <= SUBREQ_CEILING, + sample_triplets: e.triplets.slice(0, args.full ? 999 : 4).map((t) => `${t.subject} >> ${t.predicate} >> ${t.object} (${t.confidence})`), + sample_nodes: e.nodes.slice(0, args.full ? 999 : 4).map((n) => `${n.name}${n.gloss ? ' — ' + n.gloss.slice(0, 30) + '…' : ''}`), + })), + }); +} + +report.totals = { + cards: totEntries, + entries_to_upsert: totEntries, + triplet_envelopes: totEnvelopes, + total_triplets: totTriplets, + total_nodes: totNodes, + max_est_subrequests_single_call: maxSub, + ceiling: SUBREQ_CEILING, + budget: args.budget, + any_envelope_over_ceiling: over, +}; + +// --- self-test:合成一張「超大卡」(20 邊 + 22 節點)證明單 envelope 會爆、分段後每段壓在預算下 --- +if (args.selfTest) { + const entities = []; + const edges = []; + for (let i = 0; i < 22; i++) entities.push(`- **實體${i}**(別名${i})— 這是實體 ${i} 的一句描述。`); + for (let i = 0; i < 20; i++) edges.push(`- 實體${i} >> 關聯到 >> 實體${i + 1}`); + const bigCard = `---\ntags: [壓測]\ngloss: 合成超大卡,測分段。\n---\n# 合成超大卡\n\n← [[notes/00-INDEX]]\n\n## 實體\n${entities.join('\n')}\n\n## 關聯\n### 內文知識關係\n${edges.join('\n')}\n`; + const plan = planCard(bigCard, 'system-dev/wiki/cards/notes/合成超大卡.md', args.repo, { budget: args.budget }); + const single = estimateEnvelopeSubrequests(plan.tripletCount, plan.nodeCount); + report.self_test = { + note: '合成 20 邊 / 22 節點 的超大卡', + if_single_envelope_est_subrequests: single, + would_crash_single: single > SUBREQ_CEILING, + segmented_into: plan.envelopes.length, + per_segment: plan.envelopes.map((e) => ({ + uri: e.source.uri, anchor: e.source.anchor, triplets: e.triplets.length, nodes: e.nodes.length, est_subrequests: e._estSubrequests, under_ceiling: e._estSubrequests <= SUBREQ_CEILING, + })), + all_segments_under_ceiling: plan.envelopes.every((e) => e._estSubrequests <= SUBREQ_CEILING), + }; +} + +if (args.json) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +// --- 人類可讀輸出 --- +const L = (s = '') => console.log(s); +L(`\n================ km-wiki-ingest DRY-RUN(不寫 live)================`); +L(`repo=${report.repo} commit=${(commit || '(none)').slice(0, 12)} budget=${args.budget} CF_ceiling=${SUBREQ_CEILING}`); +L(`卡片來源根:${path.relative(args.repoPath, cardsRoot)} 找到 ${cardPaths.length} 張卡\n`); + +for (const c of report.cards) { + L(`── 卡片:${c.relPath}`); + L(` ENTRY(base POST /entries 或 kbdb_upsert_block,冪等鍵 page_name):`); + L(` page_name = ${c.entry.page_name}`); + L(` entry_type = ${c.entry.entry_type}`); + L(` metadata.embed= ${c.entry['metadata.embed']} content_hash=${c.entry.content_hash} bytes=${c.entry.content_bytes}`); + L(` tags = ${JSON.stringify(c.entry.tags)}`); + L(` TRIPLET ENVELOPE(s)(POST graph /triplets/ingest;分段數=${c.envelopeCount}):`); + for (const e of c.envelopes) { + L(` • uri=${e['source.uri']}${e['source.anchor'] ? ' anchor=' + e['source.anchor'] : ''}`); + L(` nodes=${e.nodes} triplets=${e.triplets} est_subrequests=${e.est_subrequests} ≤ceiling? ${e.under_ceiling ? 'YES' : 'NO ⚠️'}`); + for (const t of e.sample_triplets) L(` - ${t}`); + if (e.sample_nodes.length) L(` nodes: ${e.sample_nodes.join(' | ')}`); + } + L(''); +} + +L(`================ 彙總 ================`); +for (const [k, v] of Object.entries(report.totals)) L(` ${k.padEnd(34)} = ${v}`); +L(` 冪等設計:`); +L(` entry → page_name(穩定鍵)+ metadata.content_hash(比對是否改動 → 未改 skip、改動 PATCH 重嵌)`); +L(` triplet → source.uri + source.content_hash(graph 現役 per-source 冪等;同 hash 整包 no-op)`); +L(` 分段 → 各段獨立 source.uri(#segNN)→ 各自獨立冪等,繞開 per-source content_hash 整包 skip`); + +if (report.self_test) { + L(`\n================ SELF-TEST:超大檔分段 ================`); + const st = report.self_test; + L(` ${st.note}`); + L(` 若不分段(單 envelope)估算 subrequest = ${st.if_single_envelope_est_subrequests} → 會炸? ${st.would_crash_single ? 'YES(> ' + SUBREQ_CEILING + ')' : 'no'}`); + L(` 分段後段數 = ${st.segmented_into},每段:`); + for (const s of st.per_segment) L(` - ${s.anchor}: triplets=${s.triplets} nodes=${s.nodes} est=${s.est_subrequests} ≤ceiling? ${s.under_ceiling ? 'YES' : 'NO ⚠️'}`); + L(` 全部段壓在上限下? ${st.all_segments_under_ceiling ? 'YES ✅' : 'NO ⚠️'}`); +} +L(''); diff --git a/registry/examples/km-wiki-ingest/tags.json b/registry/examples/km-wiki-ingest/tags.json new file mode 100644 index 0000000..ec4f038 --- /dev/null +++ b/registry/examples/km-wiki-ingest/tags.json @@ -0,0 +1 @@ +["ingest", "kbdb", "wiki", "mechanical", "cron", "webhook", "graph", "triplet", "no-llm"] diff --git a/registry/examples/km-wiki-ingest/workflow.yaml b/registry/examples/km-wiki-ingest/workflow.yaml new file mode 100644 index 0000000..93b7a81 --- /dev/null +++ b/registry/examples/km-wiki-ingest/workflow.yaml @@ -0,0 +1,105 @@ +name: km_wiki_ingest_drain +description: > + Phase 0 限速 drain:cron 每 tick 只處理「一張卡」→ 機械解析成 entry + triplet envelope + → 冪等寫 KBDB(base entry / graph triplet)。反覆跑直到全庫 drain 完。 + 來源=repo 的 system-dev/wiki/cards/**/*.md(人工精耕卡,非裸筆記,無 LLM)。 + 穩態(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 分段(每段獨立冪等)。 +# ⟹ 任何單一 graph 呼叫都不會再破頂。 + +flow: + - "watch_cron >> ON_SUCCESS >> pick_next_card" + - "pick_next_card >> ON_SUCCESS >> fetch_card" + - "fetch_card >> ON_SUCCESS >> parse_card" + - "parse_card >> ON_SUCCESS >> upsert_entry" # 卡片 → base entry(embed=true),冪等 + - "upsert_entry >> ON_SUCCESS >> post_envelopes" # wikilink/typed-edge → graph triplet + - "post_envelopes >> 對每個 envelope >> post_one_envelope" # 分段時多段,各段獨立冪等 + +config: + # 1) 排程 tick:慢推。每 2 分鐘一張卡=限速(Phase 0 唯一需要 rate-limit 之處)。 + watch_cron: + component: cron + cron_expr: "*/2 * * * *" + description: "每 2 分鐘 drain 一張卡(限速慢推,避免 CF 額度與 subrequest 壓力)" + + # 2) 取下一張待處理卡(cursor drain)。用 Gitea contents API 列 cards 目錄 + 一個游標 block + # 記「處理到哪」。回傳單一 { rel_path, download_url, content_hash?(git blob sha) }。 + # 註:list + cursor 的細節可用 http_request(Gitea API) + set/string_ops 組;此處給語意佔位。 + pick_next_card: + component: http_request + method: GET + url: "https://git.uncle6.me/api/v1/repos/{{repo}}/contents/system-dev/wiki/cards?ref={{ref}}" + headers: + Authorization: "token {{gitea_token}}" + Accept: "application/json" + # 下游用 filter/set 取「游標之後第一張、且 .md、且非 00-INDEX」的一張。 + + # 3) 抓卡片全文(Gitea raw)。 + fetch_card: + component: http_request + method: GET + url: "{{pick_next_card.next.download_url}}" + 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 元件)。 + 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) + + # 5) 卡片 → base entry,冪等 upsert(page_name 當鍵;找到 PATCH、沒有 POST)。 + # metadata.embed=true → base embed 模組會補嵌 → 語意可搜。 + upsert_entry: + 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}}" + # ⚠️ 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 冪等)。 + + # 6) triplet envelope(可能多段)→ 逐段 POST graph /triplets/ingest。 + # graph 端 per-source(uri+content_hash) 冪等:同 hash 整包 no-op;分段各段 uri 不同 → 各自獨立冪等。 + post_envelopes: + component: foreach_control + items: "{{parse_card.envelopes}}" + item_key: envelope + + post_one_envelope: + component: http_request + method: POST + url: "{{graph_url}}/triplets/ingest" + headers: + Content-Type: "application/json" + X-Arcrun-API-Key: "{{graph_api_key}}" + body_json: "{{envelope}}" # envelope 已符合 ingest-candidate.json 契約(禁止欄位已排除) + +# ── 執行環境變數(部署時注入;此檔不放密鑰)── +# repo=Leo/notes ref=main gitea_token= +# kbdb_url=https://arcrun-kbdb.leo21c.workers.dev kbdb_api_key= +# graph_url= graph_api_key=leo +# +# ═══════════════════════════════════════════════════════════════════════════ +# §穩態變體(km_wiki_ingest_delta):Gitea push webhook → 只處理 delta 檔 +# ═══════════════════════════════════════════════════════════════════════════ +# 觸發 = Gitea repo Settings → Webhooks → 指向 arcrun(cypher-executor) 的 workflow webhook URL。 +# ⚠️ 這是 Gitea → Cloudflare(arcrun),非 GitHub Actions → 不觸 GitHub flag 紅線(D4/D20)。 +# 只把上面 flow 的 watch_cron/pick_next_card 換成: +# input(webhook payload)>> ON_SUCCESS >> collect_changed +# collect_changed = 從 payload.commits[].{added,modified} 濾出 system-dev/wiki/cards/**/*.md +# >> foreach 檔 >> fetch_card >> parse_card >> upsert_entry >> post_envelopes(同上) +# 量小、天生不撞頂、不需限速;靠 graph/entry 冪等自動 skip 未變檔。 From d93dc4e35069d4ee17bd81b0b993d476bd0f2d6b Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 6 Jul 2026 04:50:08 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(km-wiki-ingest):=20parse=5Fcard=20?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E9=80=9A=E7=94=A8=20code=20=E9=9B=B6?= =?UTF-8?q?=E4=BB=B6=EF=BC=9B=E5=88=AA=20domain=20=E9=9B=B6=E4=BB=B6=20km?= =?UTF-8?q?=5Fwiki=5Fcard=5Fparse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o --- .../km-wiki-ingest/component-contract.yaml | 65 --- .../examples/km-wiki-ingest/description.md | 10 +- .../km-wiki-ingest/lib/card-to-envelope.mjs | Bin 14058 -> 14396 bytes .../examples/km-wiki-ingest/lib/dry-run.mjs | 3 + .../examples/km-wiki-ingest/workflow.yaml | 373 +++++++++++++++++- 5 files changed, 366 insertions(+), 85 deletions(-) delete mode 100644 registry/examples/km-wiki-ingest/component-contract.yaml diff --git a/registry/examples/km-wiki-ingest/component-contract.yaml b/registry/examples/km-wiki-ingest/component-contract.yaml deleted file mode 100644 index 7732f7f..0000000 --- a/registry/examples/km-wiki-ingest/component-contract.yaml +++ /dev/null @@ -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 上限下。 diff --git a/registry/examples/km-wiki-ingest/description.md b/registry/examples/km-wiki-ingest/description.md index 369e9ad..da1660c 100644 --- a/registry/examples/km-wiki-ingest/description.md +++ b/registry/examples/km-wiki-ingest/description.md @@ -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`。 diff --git a/registry/examples/km-wiki-ingest/lib/card-to-envelope.mjs b/registry/examples/km-wiki-ingest/lib/card-to-envelope.mjs index 46ed7ed70015d4d8fd8aa8b55592adc3288df0cc..51b8c8d1ae9bcff7e6803563efcd39d90c14ee78 100644 GIT binary patch delta 497 zcmYL_Pfrs;7{(L0@eTC!COw*1lwiCXZ^m#jo@LxFy0P7Dwva+NFr~E6rez_lCM<|C zjcw7T3q?t#1NdFK^UuqE1!mbM%w>{!pLyQr`Tft4{OQTWT137|>~XgX^O}{>=RqD^ zgu{!7Y`w5Gd&x{anT|-KfXvK$YZ(kPYnVE`dJ~ZvtKA4vWVql*@urLLS)#{hfyF4DbNU;n5na*VP~P)4dyKk@+$nCT6tK z5v4%lKE%<+$zq*mnG42;c!kPIw})Wl@|L}r)|caJgiAIm!ra@}K)0&WbGhGU?q67% zRtNNghVr`7s3-0|?IV*^I0uV`+dY^SnP;k%J4|fdqRe0Lk z3^d~Xp4nXb`cMwgB8B3Tl+3(GZIgg>erbt{cf;m`EY-#U#}saZ diff --git a/registry/examples/km-wiki-ingest/lib/dry-run.mjs b/registry/examples/km-wiki-ingest/lib/dry-run.mjs index abfed45..5679533 100644 --- a/registry/examples/km-wiki-ingest/lib/dry-run.mjs +++ b/registry/examples/km-wiki-ingest/lib/dry-run.mjs @@ -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) diff --git a/registry/examples/km-wiki-ingest/workflow.yaml b/registry/examples/km-wiki-ingest/workflow.yaml index 93b7a81..d86a6a5 100644 --- a/registry/examples/km-wiki-ingest/workflow.yaml +++ b/registry/examples/km-wiki-ingest/workflow.yaml @@ -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: From b87c18df601158c7fff460594ab190155f5397c6 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 6 Jul 2026 05:38:10 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(km-wiki-ingest):=20code=20=E7=AF=80?= =?UTF-8?q?=E9=BB=9E=20post=20=E5=89=8D=E5=89=9D=E9=99=A4=20envelope=20?= =?UTF-8?q?=E7=9A=84=20=5FestSubrequests=EF=BC=88graph=20strict=20schema?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo21c 實 ingest 發現:graph /triplets/ingest 為 strict schema,會以 422 unrecognized_keys 拒絕 planEnvelopes 掛在 envelope 上的診斷鍵 _estSubrequests。 修:parse_card 內聯碼在 return 前把每個 envelope 的所有 _-前綴鍵剝除,使 post_one_envelope 的 body_json={{envelope}} 為 ingest-candidate 契約乾淨 payload (source/extractor/nodes/triplets)。已驗:剝除後 entry/nodes/triplets 與原 planCard 逐欄一致。實 ingest(直接驅動同資料流)已用此剝除成功寫入 15 triplets。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o --- registry/examples/km-wiki-ingest/workflow.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/registry/examples/km-wiki-ingest/workflow.yaml b/registry/examples/km-wiki-ingest/workflow.yaml index d86a6a5..0f1632d 100644 --- a/registry/examples/km-wiki-ingest/workflow.yaml +++ b/registry/examples/km-wiki-ingest/workflow.yaml @@ -388,7 +388,17 @@ config: 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 || {}); + // 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}}"