2 Commits

Author SHA1 Message Date
Leo fe9fbe1dab feat(cli): 路徑 B 裸原文萃取 + ANTHROPIC_API_KEY 直打 API(整分庫完整 ingest)
首版只做路徑 A(3 張策展卡)。本版加:
- 路徑 B:掃 journals/*.md、pages/*.md 原始筆記,Haiku 精耕(gloss)+萃三元組
  → 對齊 design §1 路徑 B,這是「不再只 3 卡、整 vault 完整 ingest」所需的線。
- callHaiku() 抽象:有 ANTHROPIC_API_KEY 直打 Anthropic Messages API(正式型態,
  不借 CC session);缺 key fallback `claude -p --model haiku` 並在 stderr 明確
  警告非正式型態(誠實,不假裝正式)。
- 空 Logseq 筆記(僅「-」)自動跳過;彙總印 processed/skipped/ingested 統計。

實測(總管 2026-07-05,notes 全庫):3 卡冪等跳過 + 07_02 3 三元組入庫 +
07_01 11 三元組入庫(total 17→31)。撞牆記於 mira#1:07_01 的 node gloss 層因
「Too many subrequests by single Worker invocation」在 persistNodes 未寫入
(三元組層在該步之前已落地,未造假、未覆蓋)。
2026-07-05 07:57:00 +00:00
Claude 773e382141 feat(cli): 最小可行 ingest CLI — walking skeleton 補跑首版(T-kb-skeleton②)
repo clone 下來是純 SDD 骨架(tasks.md T0.5~T5 全未開始,零程式碼)。這支
scripts/ingest-cli.mjs 是打穿「Leo/notes → Haiku 萃取 triples → POST
kbdb-graph-plugin /triplets/ingest」這條線的最小版本,走路徑 A 簡化版
(拉之前 cloud-worker 精耕好的 wiki 卡,而非裸 journal 原文)。

Haiku 呼叫走 `claude -p --model haiku` CLI 子行程(沙盒無 ANTHROPIC_API_KEY,
用已登入 CC session 授權繞過,仍是真 Haiku 推論,非直打 API — 細節見
docs/HANDOFF-cloud-worker-2026-07-03.md)。

實測對 3 張卡跑過,2 張乾淨端到端成功(curl 驗證見 HANDOFF),1 張因
第一輪跑批次時 180s client timeout 中途砍掉,留下 2/7 的半殘資料 +
暴露一個真實設計坑:POST /triplets/ingest 非原子、幂等 dedup 用
content_hash 比對會讓半殘狀態被永久當「已處理」跳過,不會自動補完。
沒有為了好看而重送覆蓋或補假資料,半殘狀態原樣留著當證據。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 09:10:19 +00:00
25 changed files with 420 additions and 3991 deletions
+5 -26
View File
@@ -60,7 +60,7 @@
},
"nodes": {
"type": "array",
"description": "節點層附帶資訊。【向量化分工(leo 2026-06-26,ingest#1 升格成契約)】ingest 在此【打標】哪些 token 要向量化 + embed 什麼;base/KBDB embed 模組【讀標執行】實際 embedding;ingest 自己不算向量。兩類節點(實體詞條 / wikilink 卡)都進 nodes[],謂詞向量見 triplets[].predicate_vector。",
"description": "節點層附帶資訊(選填)。entity_type 與 gloss 是【節點】屬性,不是【邊】屬性 → 放這裡,不放 triplets。graph 用 gloss 去 embed(每節點一句,不是裸詞)、用 entity_type 去 typing。",
"items": {
"type": "object",
"required": ["name"],
@@ -69,26 +69,11 @@
"name": {
"type": "string",
"minLength": 1,
"description": "節點名(須對應某 triplet 的 subject/object 原字面)。實體詞條=正規名;wikilink 卡=卡標題。"
},
"id": {
"type": "string",
"description": "去重鍵。wikilink 卡用【檔名】→ 一卡一 node,被多條邊指到也只 embed 一次,不以出現次數重複。實體詞條用正規名。選填(無則以 name 去重)。"
"description": "節點名(須對應某 triplet 的 subjectobject 原字面)。"
},
"gloss": {
"type": "string",
"description": "一句話描述。base embed 對【名 + gloss 一起】embedding(實體同義詞字面差太遠,靠描述拉近)。選填(建議 deep tier 產)。"
},
"aliases": {
"type": "array",
"items": { "type": "string" },
"description": "同義詞(如『黃仁勳』/『Jensen Huang』)。base 歸一(collapse)成同一 node。選填。"
},
"embed": {
"type": "boolean",
"default": true,
"description": "【向量化打標】此節點要不要進向量庫。true=base 讀標去 embed(名+gloss);false=base 看到就不理(如結構符號/散文不該進 nodes[],真進了標 false)。預設 true(實體詞條與 wikilink 卡都要)。",
"$comment": "ingest 打標,base 讀標執行。embed 動作歸 base embed 模組,ingest 不算向量。"
"description": "一句話描述,供 embedding。例如 'Graph RAG — 用關係遍歷檢索、保住異見的 RAG 變體'。選填(建議 deep tier 產)。"
},
"entity_type": {
"type": "string",
@@ -101,7 +86,7 @@
"triplets": {
"type": "array",
"minItems": 1,
"description": "邊(關係)。ingest 只產原始 (s,p,o) + confidence + 謂詞向量打標。端點(s/o)以字面 match nodes[].name。",
"description": "邊(關係)。ingest 只產原始 (s,p,o) + confidence。",
"items": {
"type": "object",
"required": ["subject", "predicate", "object"],
@@ -110,16 +95,10 @@
"subject": { "type": "string", "minLength": 1, "description": "主詞(實體名,須與 nodes[].name 對得上若有提供)" },
"predicate": { "type": "string", "minLength": 1, "description": "謂詞(關係)" },
"object": { "type": "string", "minLength": 1, "description": "受詞(目標實體或值)" },
"predicate_embed": {
"type": "boolean",
"default": true,
"description": "【謂詞向量化打標】謂詞要不要 embed。base 讀標 → embed【謂詞裸詞,無描述】(謂詞同義詞字面本就近,如『參考』/『參照』,裸詞 embed 即自動聚類),存 edge 的 predicate_vector。為支援『關係過濾』查詢(查『參考』不漏『參照』)→ 預設 true。embed 動作歸 base,ingest 只打標。",
"$comment": "ingest 打標,base 讀標執行 embed。"
},
"confidence":{ "type": "number", "minimum": 0, "maximum": 1, "default": 1.0, "description": "萃取可信度。淺萃可附自評;graph 不據此過濾,只記錄。" }
}
}
}
},
"$comment": "禁止欄位(graph 領域,ingest 絕不可送): id(節點去重鍵的 id 例外,那是 ingest 提供的去重鍵非 record id) / clusters / bridge_score / created_at / updated_at / 以及 triplet 上的 subject_entity_type|object_entity_type(類型只走 nodes[])。【向量化分工】ingest 打標(embed/predicate_embed + 帶 gloss/aliases),base/KBDB embed 模組讀標執行 embedding,ingest 不算向量。結構符號(>>/←)與給人讀的散文(## 摘要)不進 envelope。"
"$comment": "禁止欄位(graph 領域,ingest 絕不可送): id / clusters / bridge_score / created_at / updated_at / 以及 triplet 上的 subject_entity_type|object_entity_type(類型只走 nodes[])。送了即違反 ingest=純餵食器的邊界,graph 應拒收或忽略。"
}
+23 -26
View File
@@ -2,47 +2,44 @@
> 唯一進度來源。狀態:[ ] 未開始 [🔄] 進行中 [x] 完成 [⏸] 卡住
> 跨專案藍圖:InkStoneCo `docs/3-specs/mira-dissolve/`。
> 實作分支:`claude/ingest-t1-t5-implementation`vitest 28 passed / tsc clean / dry-run 乾淨)。
## T0 repo 骨架
## T0 repo 骨架(本輪)
- [x] 0.1 建 public repo `uncle6me-web/kbdb-ingest-plugin`
- [x] 0.2 CLAUDE.md(上游指針 + ingest 鐵律)+ README + .gitignore
- [x] 0.3 `contracts/ingest-candidate.json`(從頂層 SDD 複製,凍結契約)
- [x] 0.4 SDD 三件式骨架`docs/3-specs/ingest-pipeline/`
- [x] 0.5 package.json / tsconfig / wrangler.toml / vitest.config(參考 kbdb-graph-pluginHono + zod-openapi,無 D1/Vectorize/AI 綁定
- [x] 0.4 SDD 三件式骨架
- [ ] 0.5 package.json / tsconfig / wrangler.toml(參考 kbdb-graph-plugin
## T1 SourceAdapterR1— `src/lib/source-adapter.ts`
## T1 SourceAdapterR1
- [x] 1.1 GitHub 拉 reporuntime git/trees + contents API,非 Actions);GitHubFetcher 介面(測試走 mock
- [x] 1.2 content-hashper-file sha256source.uri = github:owner/repo@pathmakeSourceUri/parseSourceUri round-trip
- [x] 1.3 被 graph `POST /graph/refresh` 代轉觸發的受理端:`POST /refresh``src/index.ts`,被動代轉、無排程)
- [ ] 1.1 GitHub 拉 reporuntime API/clone,非 Actions
- [ ] 1.2 content-hashper-filesource.uri = github:owner/repo@path
- [ ] 1.3 被 KBDB MCP `refresh` 代轉觸發的接口
## T2 採取(R2,路徑 A 優先)— `src/lib/harvest.ts`
## T2 採取(R2,路徑 A 優先)
- [x] 2.1 採取本地 CC 已建三元組 + gloss(template 1.8.0+ 格式:frontmatter gloss、`## 實體``## 關聯` typed-edge;卡對卡 vs 內文端點分流
- [x] 2.2 cherry-pick `_kbdb_client.py` → 改純餵食器 `src/lib/graph-client.ts`POST envelope**不寫 KBDB/base**
- [ ] 2.1 本地 CC 已建三元組 + gloss(用了 system-dev-template 的 repo
- [ ] 2.2 cherry-pick `polaris/mira/tools/_kbdb_client.py` → 改純餵食器(POST envelope,不寫 KBDB
## T3 extractR3,路徑 B fallback— `src/lib/extract.ts`
## T3 extractR3,路徑 B fallback
- [x] 3.1 cherry-pick `wiki_synthesis.yaml` classify 模式 → extract promptJSON nodes[]+triplets[]
- [x] 3.2 模型用戶可選(意圖非型號,LlmCaller 介面,預設 shallow/Haiku、deep/Claude via CC
- [ ] 3.3 模型測試集(中文 + 人類暗示樣本,轉回歸測試)— **deferred**(先跑預設;護欄 + parse 已有單元測試)
- [x] 3.4 JSON-fail 升級閘(淺萃 fail/過稀 → 升 deep 一次
- [x] 3.5 第一版不 embed仍【打標】embed/predicate_embed 供未來 base 讀標;embed 動作等 Arcrun #7
- [x] 3.x 端點對齊硬自檢護欄(`src/lib/endpoint-check.ts`leo 壓測 14→0;自檢 + autoAlign 補齊)
- [ ] 3.1 cherry-pick `wiki_synthesis.yaml` classify / 兩 skill block
- [ ] 3.2 模型用戶可選 + 品質門檻白名單(預設 Haiku,深萃 Claude via CC
- [ ] 3.3 模型測試集(中文 + 人類暗示樣本,轉回歸測試)— deferred,先跑預設
- [ ] 3.4 JSON-fail 升級閘(淺萃失敗升 deep
- [ ] 3.5 第一版不 embedembed base vectorizeInkStoneCo T2.4
## T4 跨 repo 織網(R4,主職)— `src/lib/weave.ts`
## T4 跨 repo 織網(R4,主職)
- [x] 4.1 匯總多 repo 三元組 → 偵測跨庫橋(同名 node 跨 ≥2 repo)+ 異見(同 s/o 對、不同謂詞);**不算 bridge_score**graph 領域,禁送)
- [ ] 4.1 匯總多 repo 三元組
## T5 輸出 + CLIR5/R6
- [x] 5.1 POST envelope 給 graph `POST /triplets/ingest`(嚴格符合 contractbuildEnvelope strict + 顯式禁送欄位自檢提早攔)。對齊【full contract】(含 embed/id/aliases/predicate_embed,總管裁定 ingest 不退
- [x] 5.2 薄 ops CLI`scripts/ingest-cli.mjs`refresh 經 Worker / pull dry-run);**不帶查詢 MCP**
- [ ] 5.1 POST envelope 給 graph `POST /triplets/ingest`(嚴格符合 contract)⏸ 待 graph 寫入端(InkStoneCo T3.3
- [ ] 5.2 薄 ops CLI手動重萃);不帶查詢 MCP
## 阻擋項 / 誠實標記
## 阻擋項
1.**端到端 ingest→graph 走通**graph receiver 已補對齊 full contract → 剩 ingest 部署 + `GRAPH_BASE_URL` 設定 → **待部署驗**,未假綠
2. ⏸ embed 依賴 base vectorizeArcrun #7)。第一版不 embed(只打標)已動。
3. T3.3 模型測試集 deferredrefresh 端 extractWorkers AI)第一版只走採取,深萃留 CLI/CC。
1.T5.1 依賴 graph `POST /triplets/ingest`InkStoneCo T3,待 graph repo 實作)
2. ⏸ embed 依賴 base vectorizeInkStoneCo T2.4)。第一版不 embed 可先動。
+95
View File
@@ -0,0 +1,95 @@
# HANDOFFcloud-worker 補跑 T-kb-skeleton2026-07-03
補跑今天 06:30 沒跑成的 cloud-worker routine,代做 sprint P3「T-kb-skeleton」(walking skeleton
notes → wiki 卡 → triples → kbdb-graph-plugin → 可查)。這份記錄只講**本 repo(ingest)份內**
撞的坑;graph 插件那邊的坑記在 `kbdb-graph-plugin` repo 自己的 commit message 裡。
## 現況起點(撞牆①):這個 repo 之前是空殼
clone 下來只有 `CLAUDE.md` / `README.md` / `contracts/ingest-candidate.json` / SDD 三件式
`docs/3-specs/ingest-pipeline/`),`tasks.md` 的 T0.5 到 T5 全部未打勾 —— **沒有任何程式碼**
不是「文件缺漏」而是「還沒開始寫」。任務指示裡「讀部署/使用文件,CLI 形態跑一次」的前提
(已有 CLI 可跑)不成立,只能自己把最小可行的 CLI 生出來才能往下走。
## 撞牆②:沒有 ANTHROPIC_API_KEY
沙盒環境變數裡沒有 `ANTHROPIC_API_KEY`(也搜過 `~/.arcrun/config.yaml` 等常見位置,
沒找到)。CLAUDE.md 說預設用 Haiku,但沒有直打 Anthropic API 的憑證。
**繞法(誠實記錄,非硬繞)**:這個沙盒本身跑在已登入的 Claude Code session 裡,
`claude` CLI 二進位可用且已授權。改用 `claude -p <prompt> --model haiku` 子行程呼叫,
這**是**真的 Haiku 推論(同一套 Anthropic 模型),只是呼叫路徑是「經 CC session 授權的
CLI 子行程」而非「直打 Anthropic API + API key」。正式版本上線前應該換回直打 API
(需要 leo 補 `ANTHROPIC_API_KEY` credential),因為:
- CLI 子行程呼叫有 session/交互開銷,不適合大量批次跑。
- 依賴一個已登入的 CC session 存在,不是獨立、可無人值守跑的服務憑證。
## 撞牆③:Haiku 偶爾吐 enum 外的 entity_type,會被 graph 端 422 擋整批
`contracts/ingest-candidate.json``nodes[].entity_type` 是 strict enum
person/event/product/market/org)。Haiku 萃取時偶爾猜出 enum 外的值(例如 "skill"),
graph 端 Zod `strict()` 驗證會直接拒收,422 打回整個 envelope(不是只丟該欄位)。
`scripts/ingest-cli.mjs` 加了送出前的過濾:非白名單值直接刪掉該欄位(寧可欄位缺,不要
整批被拒)。這是 ingest 端的責任(契約寫的很清楚「entity_type 沒把握就不要填」,
但沒堵住模型亂填的可能)。
## 撞牆④(比較重要):POST /triplets/ingest 不是原子的,client 中斷會留半殘資料
第一輪批次跑(3 張卡)用 `timeout 180` 包整支 CLI,結果卡 2`Prompt能力即拆解自己邏輯的能力.md`
萃取完 7 個 triplets 後,POST 到一半整支 node 行程被 `timeout` 砍掉 —— 但 graph 端已經
把其中 2 個 triplet 寫進去了(因為 `ingestEnvelope` 是 for-loop 逐條 `createTriplet`
不是一次性交易)。事後查 `GET /triplets` 證實:這張卡的 source_uri 底下只有 2/7 條,
不是 0 條也不是 7 條,卡在中間。
**更麻烦的是幂等性设计跟这个情境对不上**`ingestEnvelope` 的 dedup 邏輯是
「同 `source.uri` 下若已有 `content_hash` 相同的 active 記錄 → 整批 skip」。
因為檔案內容沒變(同一份卡片重跑),`content_hash` 一定相同 —— 意味著**這 2/7 的半殘狀態
會被後續重跑永久當成「已處理過」直接跳過,不會自動補完**。目前唯一的修復方式是人工介入
(改內容強制 hash 變化,或直接呼叫 graph 的單條 `POST /triplets` 補寫缺的 5 條)。
這次沒有為了掩蓋而重送假造一致的資料——是老實留著,另外用**沒有 timeout 限制**重新跑了
第 3 張卡(乾淨的 5/5),第 2 張的半殘狀態原樣留在 base 裡當作真實證據,可用下面的
curl 驗證。
建議記入正式設計(不是這次補跑範圍,留給 leo/graph 端評估):
- `POST /triplets/ingest` 對於「同 hash 但實際 triplet 數量對不上已寫入數量」的情況,
應該要能偵測並允許補完,而不是無條件 skip。
- 或者 ingest 端自己在 POST 前後做一次「數量核對」,不一致就重試/告警,而不是默默放過。
## 這次做了什麼(scripts/ingest-cli.mjs
最小可行 CLI,只走「路徑 A 簡化版」:不是拉「已存在的裸三元組」(notes 裡沒有這種東西),
而是拉**已經被之前一輪 cloud-worker 精耕過的 wiki 卡**`Leo/notes`
`system-dev/wiki/cards/notes/*.md`2026-07-02 產出的 3 張),對每張卡用 Haiku 萃取
triples + node gloss,組 `contracts/ingest-candidate.json` 規定的 envelope
POST 給 graph 插件的 `/triplets/ingest`
`source.uri` 格式從契約範例的 `github:<owner>/<repo>@<path>` 改成
`gitea:Leo/notes@<path>`(因為整個堆疊都在 Gitea 不是 GitHub,契約本身沒有嚴格要求
一定要 `github:` 前綴,只要求非空字串 + 穩定識別)。
## 未做(老實列出,不是這次範圍)
- T1 SourceAdapter 自動化(GitHub/Gitea API 拉 repo + per-file content-hash 自動判斷變動)— 這次是手動 clone + 手動指路徑。
- T2.2 cherry-pick `polaris/mira/tools/_kbdb_client.py` — 沒做,`ingest-cli.mjs` 是重新寫的最小版本,不是 cherry-pick 移植。
- T3.1/3.4 完整 extract SOPJSON-fail 升級 deep tier 等)— 這次沒做失敗重試/升級邏輯,Haiku JSON 解析失敗就直接跳過該卡。
- T4 跨 repo 織網 — 完全沒碰,這次只餵了 `Leo/notes` 一庫。
- 沒建 `package.json`/`wrangler.toml`T0.5)— `ingest-cli.mjs` 是純 Node script,不是 Worker,跟原規劃的「插件也是 CF Worker」形態不同,值得之後討論薄 CLI 到底要不要是 Worker。
## 驗證用 curl(供複驗)
```bash
BASE=https://kbdb-graph-plugin.leo21c.workers.dev
# 完整批次總覽
curl -sS "$BASE/triplets/stats" | jq .
# 卡1Gitea 卡)— 應該 7/7 乾淨
curl -sS "$BASE/triplets?subject=Gitea" | jq '.count'
# 卡2(Prompt能力卡)— 應該只有 2/7(半殘證據,見撞牆④)
curl -sS "$BASE/triplets?limit=100" | jq '[.triplets[] | select(.source_uri == "gitea:Leo/notes@system-dev/wiki/cards/notes/Prompt能力即拆解自己邏輯的能力.md")] | length'
# 卡3(程式化邏輯卡)— 應該 5/5 乾淨(第二輪無 timeout 補跑)
curl -sS "$BASE/triplets?limit=100" | jq '[.triplets[] | select(.source_uri == "gitea:Leo/notes@system-dev/wiki/cards/notes/程式化邏輯可圖解任何主題不限AI.md")] | length'
```
-2673
View File
File diff suppressed because it is too large Load Diff
-25
View File
@@ -1,25 +0,0 @@
{
"name": "kbdb-ingest-plugin",
"version": "0.1.0",
"private": true,
"description": "KBDB-ingest 插件:純餵食器——GitHub 拉 + 採取/萃取三元組候選 + 跨庫織網 → POST envelope 給 kbdb-graph-plugin。不碰儲存。",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run",
"test:watch": "vitest",
"ingest": "node scripts/ingest-cli.mjs"
},
"dependencies": {
"@hono/zod-openapi": "^1.2.4",
"hono": "^4.7.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250219.0",
"typescript": "^5.7.0",
"vitest": "^3.1.0",
"wrangler": "^4.0.0"
}
}
+297 -105
View File
@@ -1,117 +1,309 @@
#!/usr/bin/env node
// 薄 ops CLI(T5.2)— 人手動觸發重萃。不帶查詢 MCP(ambient 餵食器沒人「問」它)。
// KBDB-ingest 薄 ops CLI — 第二版(2026-07-05,總管:整分庫完整 ingest)
//
// 兩種模式
// ingest refresh <github:owner/repo@path> 經部署的 Worker /refresh 重萃單一來源
// ingest pull <owner/repo> [root] 本地 dry-run:拉 + 列出會送的 envelope(不 POST
// 相對於首版(2026-07-03 walking skeleton,僅路徑 A + 3 張策展卡)新增
// 1. 路徑 B(裸原文萃取):掃 journals/*.md、pages/*.md 這類「未精耕的原始筆記」,
// 用 Haiku 直接 extract 成 (s,p,o)+node glossgloss 即精耕摘要,對齊 design §1 路徑 B)。
// → 這是「不再只 3 卡、把整個 notes vault 完整 ingest」所需的那條線。
// 2. Haiku 呼叫抽象化 callHaiku()
// - 有 ANTHROPIC_API_KEY → 直打 Anthropic Messages API(正式型態,不借 CC session)。
// - 沒有 → fallback `claude -p --model haiku` 子行程(沿用本機 CC session 授權)。
// 兩者都是真 Haiku 推論;差別只在授權路徑。缺 key 時會在 stderr 明確警告「非正式型態」,
// 不假裝正式(誠實:正式版需 Environment 注入 ANTHROPIC_API_KEY)。
//
// 設定走 env
// KBDB_INGEST_URL 已部署的 ingest Worker baserefresh 模式用)
// GRAPH_BASE_URL graph 寫入端(pull --post 用)
// GITHUB_TOKEN 拉私庫用(公庫可空)
// 預設行為(不帶 --card/--raw):路徑 Asystem-dev/wiki/cards/**/*.md
// + 路徑 Bjournals/*.md、pages/*.md,跳過空檔)=整分庫一次過。
//
// 鐵律:CLI 不碰儲存;refresh 經 Worker、pull --post 經 graph 寫入端。觸發=人手動(無排程)。
// 用法:
// node scripts/ingest-cli.mjs --notes-repo <clone路徑> --graph-url <graph plugin base URL>
// [--card <relpath>]... [--raw <relpath>]... [--cards-only] [--raw-only] [--dry-run]
import process from 'node:process';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import path from 'node:path';
const [, , cmd, arg, arg2] = process.argv;
const HAIKU_MODEL = 'claude-haiku-4-5';
async function sha256hex(text) {
const data = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', data);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
function ghHeaders() {
const h = { Accept: 'application/vnd.github+json', 'User-Agent': 'kbdb-ingest-cli' };
if (process.env.GITHUB_TOKEN) h.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
return h;
}
async function ghGetFile(owner, repo, path) {
const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`;
const res = await fetch(url, { headers: ghHeaders() });
if (!res.ok) throw new Error(`github ${owner}/${repo}@${path}: ${res.status}`);
const body = await res.json();
const text = body.encoding === 'base64' ? Buffer.from(body.content, 'base64').toString('utf-8') : body.content;
return { text, commit: body.sha };
}
async function ghListMarkdown(owner, repo, root = '') {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/HEAD?recursive=1`, { headers: ghHeaders() });
if (!res.ok) throw new Error(`github list ${owner}/${repo}: ${res.status}`);
const body = await res.json();
const prefix = root.replace(/^\/+|\/+$/g, '');
return (body.tree || [])
.filter((e) => e.type === 'blob' && e.path.endsWith('.md'))
.map((e) => e.path)
.filter((p) => (prefix ? p === prefix || p.startsWith(prefix + '/') : true));
}
// 極簡採取(鏡射 src/lib/harvest.tsCLI dry-run 用,不引 TS)。
function harvest(md) {
const fm = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(md);
const body = fm ? fm[2] : md;
const gloss = fm && /^gloss:\s*(.+)$/m.exec(fm[1]) ? /^gloss:\s*(.+)$/m.exec(fm[1])[1].trim() : undefined;
const title = /^#\s+(.+)$/m.exec(body)?.[1]?.trim();
const sec = (h) => new RegExp(`^##\\s+${h}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'm').exec(body)?.[1] || '';
const nodes = [];
if (title) nodes.push({ name: title, gloss, embed: true });
for (const line of sec('實體').split('\n')) {
const m = /^-\s*\*\*(.+?)\*\*\s*(?:(.+?))?\s*(?:[—-]\s*(.+))?$/.exec(line.trim());
if (m) nodes.push({ name: m[1].trim(), gloss: m[3]?.trim() || undefined, embed: true });
function parseArgs(argv) {
const out = { cards: [], raws: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--notes-repo') out.notesRepo = argv[++i];
else if (a === '--graph-url') out.graphUrl = argv[++i];
else if (a === '--card') out.cards.push(argv[++i]);
else if (a === '--raw') out.raws.push(argv[++i]);
else if (a === '--cards-only') out.cardsOnly = true;
else if (a === '--raw-only') out.rawOnly = true;
else if (a === '--dry-run') out.dryRun = true;
}
const triplets = [];
for (const line of sec('關聯').split('\n')) {
const m = /^(.+?)\s*>>\s*(.+?)\s*>>\s*(.+?)$/.exec(line.replace(/^-\s*/, '').trim());
if (m) {
const clean = (s) => s.replace(/\[\[|\]\]|\*\*/g, '').trim();
triplets.push({ subject: clean(m[1]), predicate: m[2].trim(), object: clean(m[3]), predicate_embed: true });
}
}
return { nodes, triplets };
return out;
}
async function doRefresh(uri) {
const base = process.env.KBDB_INGEST_URL;
if (!base) throw new Error('KBDB_INGEST_URL 未設(指向已部署的 ingest Worker');
const res = await fetch(base.replace(/\/$/, '') + '/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uri }),
});
console.log(JSON.stringify(await res.json(), null, 2));
}
async function doPull(ownerRepo, root) {
const [owner, repo] = ownerRepo.split('/');
if (!owner || !repo) throw new Error('用法:ingest pull <owner/repo> [root]');
const paths = await ghListMarkdown(owner, repo, root || '');
console.error(`[ingest] ${owner}/${repo}: ${paths.length} 個 MD`);
const envelopes = [];
for (const path of paths) {
const { text, commit } = await ghGetFile(owner, repo, path);
const { nodes, triplets } = harvest(text);
if (!triplets.length) continue; // 採不到(非 template 卡)→ dry-run 跳過(CLI 不做 extract
envelopes.push({
source: { uri: `github:${owner}/${repo}@${path}`, content_hash: await sha256hex(text), commit },
extractor: { model: 'local-harvest', tier: 'shallow' },
nodes,
triplets,
});
}
console.error(`[ingest] 採取出 ${envelopes.length} 個 envelope(共 ${envelopes.reduce((n, e) => n + e.triplets.length, 0)} 三元組)`);
console.log(JSON.stringify(envelopes, null, 2));
}
try {
if (cmd === 'refresh' && arg) await doRefresh(arg);
else if (cmd === 'pull' && arg) await doPull(arg, arg2);
else {
console.error('用法:\n ingest refresh <github:owner/repo@path>\n ingest pull <owner/repo> [root]');
process.exit(2);
}
} catch (e) {
console.error('[ingest] 錯誤:', e.message);
const args = parseArgs(process.argv.slice(2));
if (!args.notesRepo || !args.graphUrl) {
console.error('用法: node ingest-cli.mjs --notes-repo <path> --graph-url <url> [--card <relpath>]... [--raw <relpath>]... [--cards-only] [--raw-only] [--dry-run]');
process.exit(1);
}
// --- Haiku 呼叫:正式 API 優先,缺 key fallback CC session CLI ---
const HAS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
if (HAS_API_KEY) {
console.error('[haiku] 使用 ANTHROPIC_API_KEY 直打 Anthropic API(正式型態)。');
} else {
console.error('[haiku] ⚠️ 環境無 ANTHROPIC_API_KEYfallback `claude -p --model haiku`(借 CC session,非正式型態)。');
}
async function callHaikuApi(prompt) {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: HAIKU_MODEL,
max_tokens: 2048,
messages: [{ role: 'user', content: prompt }],
}),
});
const json = await res.json();
if (!res.ok) {
throw new Error(`Anthropic API ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
}
const text = (json.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join('');
if (!text) throw new Error(`Anthropic API 回應無 text block: ${JSON.stringify(json).slice(0, 300)}`);
return text;
}
function callHaikuCli(prompt) {
return execFileSync('claude', ['-p', prompt, '--model', 'haiku'], {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
}
async function callHaiku(prompt) {
return HAS_API_KEY ? await callHaikuApi(prompt) : callHaikuCli(prompt);
}
// 標記本次萃取實際走的授權路徑,寫進 envelope.extractor.model(可追溯)。
const EXTRACTOR_MODEL = HAS_API_KEY
? `${HAIKU_MODEL} (Anthropic API, 總管 2026-07-05)`
: `${HAIKU_MODEL} (via 'claude -p --model haiku' CLI subprocess, 總管 2026-07-05)`;
// --- 卡片/原文列舉 ---
function walkMd(dir) {
const out = [];
if (!existsSync(dir)) return out;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walkMd(p));
else if (entry.name.endsWith('.md') && entry.name !== '.gitkeep') out.push(p);
}
return out;
}
function defaultCards(notesRepo) {
return walkMd(path.join(notesRepo, 'system-dev', 'wiki', 'cards'));
}
// 路徑 B 預設來源:journals/、pages/Logseq vault 的原始筆記)。
function defaultRaws(notesRepo) {
const out = [];
for (const sub of ['journals', 'pages']) {
const dir = path.join(notesRepo, sub);
if (existsSync(dir)) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isFile() && entry.name.endsWith('.md')) out.push(path.join(dir, entry.name));
}
}
}
return out;
}
// Logseq 空檔=內容只有「-」或空白。跳過(送空 envelope 無意義且 contract 要 triplets≥1)。
function isEmptyNote(content) {
return content.replace(/[-\s]/g, '').length === 0;
}
function gitCommit(repoPath) {
try {
return execFileSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
} catch {
return undefined;
}
}
function sha256(text) {
return createHash('sha256').update(text).digest('hex');
}
// contract 的 nodes[].entity_type 是 strict enumHaiku 偶爾猜 enum 外值(如 "skill")會讓 graph 端
// Zod strict() 422 整批。寧可拿掉這選填欄位也不要整批被拒(不確定就不填,比亂填誠實)。
const ALLOWED_ENTITY_TYPES = new Set(['person', 'event', 'product', 'market', 'org']);
function sanitizeNodes(nodes) {
if (!Array.isArray(nodes)) return nodes;
for (const n of nodes) {
if (n.entity_type && !ALLOWED_ENTITY_TYPES.has(n.entity_type)) delete n.entity_type;
}
return nodes;
}
function parseHaikuJson(result, label) {
// Haiku 有時包 ```json fence,保守剝一層。
const cleaned = result.trim().replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
try {
return JSON.parse(cleaned);
} catch (e) {
throw new Error(`${label} 輸出非合法 JSON${e.message}\n原始輸出:${result.slice(0, 500)}`);
}
}
// 路徑 A:已精耕卡 → 萃三元組。
async function extractFromCard(cardText, cardTitle) {
const prompt = `你是知識圖譜萃取器。讀以下一張「精耕 wiki 卡」(已經是人類編輯過的摘要,不是裸筆記),
從中萃取 (subject, predicate, object) 三元組,捕捉卡片講的核心關係/主張/因果鏈。
規則:
- 只輸出 JSON,不要任何其他文字、不要 markdown code fence。
- 格式:{"nodes":[{"name":"...","gloss":"...","entity_type":"person|event|product|market|org"(選填,不確定就不填)}],"triplets":[{"subject":"...","predicate":"...","object":"...","confidence":0.0~1.0}]}
- triplets 至少 1 條,抓卡片「重點」段落的核心關係即可,不用鉅細靡遺。
- entity_type 沒把握就不要填這個欄位(比亂填更誠實)。
- subject/object 用簡短名詞短語(可當圖節點),不要整句話塞進去。
卡片標題:${cardTitle}
卡片內容:
${cardText}`;
return parseHaikuJson(await callHaiku(prompt), 'Haiku(card)');
}
// 路徑 B:裸原始筆記 → 精耕(節點 gloss 即摘要)+ 萃三元組(design §1 路徑 B)。
async function extractFromRaw(rawText, noteTitle) {
const prompt = `你是知識圖譜萃取器,處理「裸筆記」——這是 Logseq 日記/頁面的原始條列(尚未精耕),
可能口語、跳躍、含個人反思。你的工作分兩步在心裡完成,只輸出最終 JSON:
(1) 精耕:先把這則裸筆記在心裡濃縮成幾個核心概念/主張(每個概念寫一句 gloss 摘要)。
(2) 萃取:從精耕結果萃出 (subject, predicate, object) 三元組,捕捉主張/因果/類比關係。
規則:
- 只輸出 JSON,不要任何其他文字、不要 markdown code fence。
- 格式:{"nodes":[{"name":"...","gloss":"精耕出的一句摘要","entity_type":"person|event|product|market|org"(選填,不確定就不填)}],"triplets":[{"subject":"...","predicate":"...","object":"...","confidence":0.0~1.0}]}
- 忽略純生活流水帳/無知識含量的條目;若整則都無可萃取的概念,回 {"nodes":[],"triplets":[]}。
- triplets 抓真正有洞見的關係即可(寧缺勿濫);每個節點盡量給 gloss(那是精耕產物)。
- subject/object 用簡短名詞短語(可當圖節點),不要整句話塞進去。
- entity_type 沒把握就不要填。
筆記標題:${noteTitle}
裸筆記內容:
${rawText}`;
return parseHaikuJson(await callHaiku(prompt), 'Haiku(raw)');
}
async function postEnvelope(graphUrl, envelope) {
const res = await fetch(graphUrl.replace(/\/$/, '') + '/triplets/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(envelope),
});
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
return { status: res.status, body: json };
}
// --- 組裝待處理清單 ---
const commit = gitCommit(args.notesRepo);
const cardPaths = args.rawOnly
? []
: (args.cards.length ? args.cards.map((c) => path.join(args.notesRepo, c)) : defaultCards(args.notesRepo));
const rawPaths = args.cardsOnly
? []
: (args.raws.length ? args.raws.map((r) => path.join(args.notesRepo, r)) : defaultRaws(args.notesRepo));
if (cardPaths.length === 0 && rawPaths.length === 0) {
console.error('找不到任何卡片或原文可處理。');
process.exit(1);
}
const items = [
...cardPaths.map((p) => ({ p, kind: 'card' })),
...rawPaths.map((p) => ({ p, kind: 'raw' })),
];
const summary = { processed: 0, skipped_empty: 0, no_triplet: 0, failed: 0, ingested: 0, deprecated: 0, posted_ok: 0, total_triplets: 0 };
for (const { p: itemPath, kind } of items) {
const relPath = path.relative(args.notesRepo, itemPath);
const content = readFileSync(itemPath, 'utf8');
const title = path.basename(itemPath, '.md');
console.log(`\n=== [${kind}] ${relPath} ===`);
if (kind === 'raw' && isEmptyNote(content)) {
console.log(' ↷ 空筆記(無實質內容),跳過');
summary.skipped_empty++;
continue;
}
let extracted;
try {
extracted = kind === 'card'
? await extractFromCard(content, title)
: await extractFromRaw(content, title);
} catch (e) {
console.error(` ✗ 萃取失敗: ${e.message}`);
summary.failed++;
continue;
}
if (!extracted.triplets || extracted.triplets.length === 0) {
console.log(' ↷ 無可萃取的三元組(裸筆記無知識含量或全為流水帳),跳過');
summary.no_triplet++;
continue;
}
sanitizeNodes(extracted.nodes);
const envelope = {
source: {
uri: `gitea:Leo/notes@${relPath}`,
content_hash: sha256(content),
commit,
},
extractor: {
model: EXTRACTOR_MODEL,
tier: 'shallow',
extracted_at: Math.floor(Date.now() / 1000),
},
nodes: extracted.nodes,
triplets: extracted.triplets,
};
console.log(` 萃出 ${envelope.triplets.length} triplets, ${(envelope.nodes ?? []).length} nodes`);
summary.processed++;
summary.total_triplets += envelope.triplets.length;
if (args.dryRun) {
console.log(' [dry-run] envelope:', JSON.stringify(envelope, null, 2));
continue;
}
const { status, body } = await postEnvelope(args.graphUrl, envelope);
console.log(` POST /triplets/ingest → HTTP ${status}`, JSON.stringify(body));
if (status >= 200 && status < 300) {
summary.posted_ok++;
if (typeof body.ingested === 'number') summary.ingested += body.ingested;
if (typeof body.deprecated === 'number') summary.deprecated += body.deprecated;
}
}
console.log('\n===== 彙總 =====');
console.log(JSON.stringify(summary, null, 2));
-87
View File
@@ -1,87 +0,0 @@
// KBDB-ingest 插件 Worker 進入點 — 純餵食器。
//
// 鐵律:不碰儲存(無 D1/Vectorize/AI 綁定)。只 POST envelope 給 graph 寫入端。
// 端點:/refresh = graph 的 POST /graph/refresh 代轉過來的受理端(人發起、非自動 fan-out)。
// refresh 收到 {uri, owner_id} → 拉該來源 → 採取/萃取 → POST envelope 給 graph。
// 不帶查詢 MCPambient 餵食器);ops 走薄 CLIscripts/ingest-cli.mjs)。
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
import { cors } from 'hono/cors';
import type { Bindings, Variables } from './types';
import { makeGitHubFetcher, parseSourceUri, contentHash, makeSourceUri } from './lib/source-adapter';
import { processSource } from './lib/pipeline';
import { makeGraphClient } from './lib/graph-client';
const app = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>();
app.onError((err, c) => {
console.error(err);
return c.json({ error: 'Internal Server Error', message: err.message }, 500);
});
app.use('*', cors({ origin: '*', allowHeaders: ['Content-Type', 'Authorization'], allowMethods: ['GET', 'POST', 'OPTIONS'] }));
app.get('/', (c) => c.json({ service: 'kbdb-ingest', tier: 'plugin', role: 'feeder', status: 'ok' }));
app.get('/health', (c) =>
c.json({ service: 'kbdb-ingest', status: 'ok', graph_url_set: Boolean(c.env.GRAPH_BASE_URL) }),
);
// POST /refresh — graph 代轉重萃某來源。被動:收一次調用 → 處理一次(無排程/webhook)。
const refreshRoute = createRoute({
method: 'post',
path: '/refresh',
request: {
body: {
content: {
'application/json': {
schema: z.object({
uri: z.string().min(1).describe("github:owner/repo@path"),
owner_id: z.string().optional(),
}),
},
},
},
},
responses: {
200: { description: 'Refreshed: pulled, harvested/extracted, posted envelope to graph' },
400: { description: 'Bad uri' },
},
tags: ['Ingest'],
});
app.openapi(refreshRoute, async (c) => {
const { uri } = c.req.valid('json');
const parsed = parseSourceUri(uri);
if (!parsed) return c.json({ error: 'uri 須為 github:owner/repo@path' }, 400);
const fetcher = makeGitHubFetcher(c.env.GITHUB_TOKEN);
const { text, commit } = await fetcher.getFile(parsed.owner, parsed.repo, parsed.path);
const file = {
uri: makeSourceUri(parsed.owner, parsed.repo, parsed.path),
path: parsed.path,
text,
content_hash: await contentHash(text),
commit,
};
// 第一版 refresh 只走採取(路徑 A);extract 模型在 Worker runtime 接 Workers AI 是後續
// CLI 端可帶 deep via CC)。採不到三元組 → 誠實回 skipped,不假萃。
const result = await processSource(file);
if (!result.envelope) {
return c.json({ refreshed: false, path: result.path, note: result.note }, 200);
}
const graph = makeGraphClient(c.env.GRAPH_BASE_URL, c.env.GRAPH_INTERNAL_TOKEN);
const post = await graph.postEnvelope(result.envelope);
return c.json(
{
refreshed: post.ok,
path: result.path,
triplets: result.envelope.triplets.length,
graph: post.ok ? post.body : { status: post.status, error: post.error, issues: (post.body as any)?.issues },
},
200,
);
});
export default app;
-49
View File
@@ -1,49 +0,0 @@
// 端點對齊硬自檢護欄(leo 真 vault 壓測實證:光寫規則 Haiku 會略過,端點對不齊 14 條;
// 寫成自檢動作後 14→0)。
//
// 規則:每條內文三元組的 subject/object 必須對得上某個 node 名(一字不差)。
// 對不齊 = 下游圖斷鏈(端點 match 不到 node)。本護欄在 envelope 出門前機械檢,
// 撈出對不齊的端點,呼叫端可選擇修補 / 丟棄 / warn。
import type { EnvelopeEdge, EnvelopeNode } from '../types';
export interface AlignmentReport {
aligned: boolean;
/** 對不齊的端點描述(給人讀 / log)。 */
unaligned: string[];
}
/**
* 檢查三元組端點是否都對得上 nodes[].name。
* 卡對卡端點(原文 `[[卡]]`)已在 harvest 去括號 → 一律以裸名比對。
*/
export function checkEndpointAlignment(nodes: EnvelopeNode[], triplets: EnvelopeEdge[]): AlignmentReport {
const names = new Set(nodes.map((n) => n.name));
const unaligned: string[] = [];
for (const t of triplets) {
for (const [role, ep] of [['subject', t.subject], ['object', t.object]] as const) {
if (!names.has(ep)) {
unaligned.push(`${role}${ep}」對不齊(${t.subject} >> ${t.predicate} >> ${t.object}`);
}
}
}
return { aligned: unaligned.length === 0, unaligned };
}
/**
* 自動補齊:對不齊的端點,把它當成新 node 補進 nodes[]embed:true,無 gloss)。
* 比丟棄三元組保守——保住邊,下游仍可 normalize。回傳補過的 nodes。
*/
export function autoAlignEndpoints(nodes: EnvelopeNode[], triplets: EnvelopeEdge[]): EnvelopeNode[] {
const names = new Set(nodes.map((n) => n.name));
const out = [...nodes];
for (const t of triplets) {
for (const ep of [t.subject, t.object]) {
if (!names.has(ep)) {
names.add(ep);
out.push({ name: ep, embed: true });
}
}
}
return out;
}
-51
View File
@@ -1,51 +0,0 @@
// envelope 組裝 + 出門前禁送欄位自檢。
//
// 一個 envelope = 一個來源檔一次萃取的產物(契約定義)。組裝後跑 EnvelopeSchema 驗證
// strict → 多帶禁送欄位會 throw,提早在 ingest 端攔,不等 graph 422)。
import {
EnvelopeSchema,
FORBIDDEN_EDGE_KEYS,
FORBIDDEN_TOP_KEYS,
type Envelope,
type EnvelopeEdge,
type EnvelopeNode,
} from '../types';
export interface BuildEnvelopeInput {
source: { uri: string; content_hash: string; anchor?: string; commit?: string; block_id?: string };
extractor: { model: string; tier: 'shallow' | 'deep'; extracted_at?: number };
nodes?: EnvelopeNode[];
triplets: EnvelopeEdge[];
}
/**
* 組 envelope 並驗證(strict)。
* - 結構符號/散文不該進;nodes/triplets 由上游(harvest/extract)已過濾。
* - 驗證失敗(多帶禁送欄位、形狀錯)→ throw ZodError,呼叫端攔(比送出去被 graph 422 早)。
*/
export function buildEnvelope(input: BuildEnvelopeInput): Envelope {
// 顯式禁送欄位自檢(除了 strict schema,多一道明確攔——上游若塞 graph 領域欄位提早炸)。
for (const n of input.nodes ?? []) {
for (const k of [...FORBIDDEN_TOP_KEYS, 'clusters']) {
if (k !== 'id' && k in (n as Record<string, unknown>)) {
throw new Error(`envelope: node「${n.name}」帶禁送欄位 ${k}graph 領域,ingest 不可送)`);
}
}
}
for (const t of input.triplets) {
for (const k of FORBIDDEN_EDGE_KEYS) {
if (k in (t as Record<string, unknown>)) {
throw new Error(`envelope: 邊「${t.subject}>>${t.object}」帶禁送欄位 ${k}(類型只走 nodes[]`);
}
}
}
const candidate: Envelope = {
source: input.source,
extractor: input.extractor,
triplets: input.triplets,
...(input.nodes && input.nodes.length ? { nodes: input.nodes } : {}),
};
// strict 驗證:等於本地版「禁送欄位 → 擋」。throw 給呼叫端。
return EnvelopeSchema.parse(candidate);
}
-110
View File
@@ -1,110 +0,0 @@
// T3 extract(路徑 Bfallback)— 裸原文無本地三元組時,ingest 自己萃 (s,p,o)+gloss。
//
// 模型用戶可選(意圖非型號):shallow=Haiku/Workers AI(預設、便宜);deep=Claude via CC(深萃、走月費)。
// JSON-fail 升級閘:shallow 解析失敗 / 萃太稀 → 升 deep 重萃一次。
// 第一版不 embedembed 等 base vectorize / Arcrun #7)——但仍【打標】embed/predicate_embed 供未來讀標。
// 端點對齊護欄:萃完用 endpoint-check 自檢 + 自動補齊(leo 壓測 14→0)。
//
// LLM 呼叫抽象成 LlmCaller 介面 → 測試走 mock,不打網路、不花錢。
import type { EnvelopeEdge, EnvelopeNode } from '../types';
import { autoAlignEndpoints, checkEndpointAlignment } from './endpoint-check';
export type ExtractTier = 'shallow' | 'deep';
export interface ExtractedGraph {
nodes: EnvelopeNode[];
triplets: EnvelopeEdge[];
}
/** 一次 LLM 萃取呼叫。回傳模型【原始文字】(期望是 JSON),由本模組負責 parse。 */
export interface LlmCaller {
/** model = 解析後的具體型號字串(供 extractor.model 記錄)。 */
readonly model: string;
call(prompt: string, text: string): Promise<string>;
}
export interface ExtractResult extends ExtractedGraph {
tier: ExtractTier;
model: string;
/** 是否因 shallow JSON-fail/過稀而升級到 deep。 */
escalated: boolean;
}
const EXTRACT_PROMPT = `你是知識圖譜萃取器。讀下面的原文,萃出三元組與實體。嚴格輸出 JSON(繁體中文內容),格式:
{
"nodes": [{"name": "正規名", "gloss": "一句話定義(這個實體是什麼)", "aliases": ["同義詞"]}],
"triplets": [{"subject": "主詞", "predicate": "動詞短語", "object": "受詞", "confidence": 0.0-1.0}]
}
規則:
- 謂詞用動詞/動詞短語(如「奠基於」「反駁」),禁名詞當謂詞。
- triplet 的 subject/object 必須對得上某個 nodes[].name(一字不差)。
- 抓深層暗示,不只表面陳述。只輸出 JSON,不要其他文字。`;
/** 解析模型輸出的 JSON(容忍 ```json fenced 區塊)。失敗 throw。 */
export function parseExtractJson(raw: string): ExtractedGraph {
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw);
const jsonText = (fenced ? fenced[1] : raw).trim();
const parsed = JSON.parse(jsonText) as Partial<ExtractedGraph>;
if (!Array.isArray(parsed.triplets) || parsed.triplets.length === 0) {
throw new Error('extract: no triplets in model output');
}
const nodes: EnvelopeNode[] = (parsed.nodes ?? []).map((n) => ({
name: String(n.name),
gloss: n.gloss ? String(n.gloss) : undefined,
aliases: Array.isArray(n.aliases) ? n.aliases.map(String) : undefined,
embed: true, // 打標 truebase 讀標執行;第一版 base 還沒接,標仍合契約)
}));
const triplets: EnvelopeEdge[] = parsed.triplets.map((t) => ({
subject: String(t.subject),
predicate: String(t.predicate),
object: String(t.object),
confidence: typeof t.confidence === 'number' ? t.confidence : undefined,
predicate_embed: true,
}));
return { nodes, triplets };
}
/** 萃太稀(門檻)→ 視為失敗、觸發升級。 */
function tooSparse(g: ExtractedGraph): boolean {
return g.triplets.length < 1;
}
/**
* extract:先用 shallowCaller 淺萃;JSON-fail 或過稀 → 若有 deepCaller 升級重萃一次。
* 萃完跑端點對齊護欄並自動補齊。deepCaller 省略 = 不升級(純 shallow)。
*/
export async function extract(
text: string,
shallowCaller: LlmCaller,
deepCaller?: LlmCaller,
): Promise<ExtractResult> {
let tier: ExtractTier = 'shallow';
let model = shallowCaller.model;
let graph: ExtractedGraph | null = null;
let escalated = false;
try {
graph = parseExtractJson(await shallowCaller.call(EXTRACT_PROMPT, text));
if (tooSparse(graph)) throw new Error('extract: shallow too sparse');
} catch {
graph = null;
}
if (!graph && deepCaller) {
escalated = true;
tier = 'deep';
model = deepCaller.model;
graph = parseExtractJson(await deepCaller.call(EXTRACT_PROMPT, text)); // deep 失敗就 throw 給呼叫端
}
if (!graph) throw new Error('extract: shallow failed and no deep caller to escalate');
// 端點對齊護欄(leo 壓測必做):自檢 + 自動補齊(保住邊,不丟)。
const aligned = autoAlignEndpoints(graph.nodes, graph.triplets);
const report = checkEndpointAlignment(aligned, graph.triplets);
// 補齊後理應全對齊;若仍有(理論上不會)留給呼叫端,但不阻斷。
void report;
return { nodes: aligned, triplets: graph.triplets, tier, model, escalated };
}
-58
View File
@@ -1,58 +0,0 @@
// T5 graph client — cherry-pick 自 polaris/mira/tools/_kbdb_client.py 的 HTTP-helper 模式,
// 但【改成純餵食器】:只 POST envelope 給 graph 寫入端,**不寫 base、不碰 D1/Vectorize/表**。
//
// 原 _kbdb_client.py 直打 base /kbdb/entries(碰儲存)——那正是 ingest 鐵律禁止的。
// 本檔保留它的「統一 http wrapper + header + 容錯回傳」骨架,把目標改成 graph 的
// POST /triplets/ingestAPI-as-Wallingest 只透過 graph HTTP 寫入端餵候選)。
import type { Envelope } from '../types';
export interface PostResult {
ok: boolean;
/** graph 回的 {skipped,ingested,deprecated}200);422/未設時 ok=false。 */
status: number;
body?: unknown;
error?: string;
}
export interface GraphClient {
postEnvelope(env: Envelope): Promise<PostResult>;
}
/**
* 真實 graph client。baseUrl 空 → 誠實回 {ok:false, error:'GRAPH_BASE_URL 未設'},不假綠
* (對齊 graph 端 refresh「未設 ingest URL 誠實回 forwarded:false」的誠實原則)。
*/
export function makeGraphClient(
baseUrl: string | undefined,
token?: string,
fetchImpl: typeof fetch = fetch,
): GraphClient {
return {
async postEnvelope(env) {
if (!baseUrl) {
return { ok: false, status: 0, error: 'GRAPH_BASE_URL 未設:graph 寫入端尚未就緒/未部署,envelope 無對象可送。' };
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
const url = baseUrl.replace(/\/$/, '') + '/triplets/ingest';
let res: Response;
try {
res = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify(env) });
} catch (e) {
return { ok: false, status: 0, error: `[graph] POST ${url}: ${(e as Error).message}` };
}
let body: unknown;
try {
body = await res.json();
} catch {
body = undefined;
}
// 422 = envelope 違規(禁送欄位/形狀)→ 不 ok,帶 graph 回的 issues 供修。
if (!res.ok) {
return { ok: false, status: res.status, body, error: `graph ${res.status} ${res.statusText}` };
}
return { ok: true, status: res.status, body };
},
};
}
-146
View File
@@ -1,146 +0,0 @@
// T2 採取(路徑 A,優先)— 從 system-dev-template 1.8.0+ 的 wiki 卡採取已建好的三元組+gloss。
//
// 本地萃成效更好(知識連結長在生產當下、有 LLM Wiki 指引),ingest 優先採取、不重萃。
// 解析卡片格式(與本 repo system-dev/wiki/cards 同源):
// frontmatter: gloss:(卡標題 node 的描述)
// ## 實體:一行一個 `- **正規名**(aliases…)— 描述句`(內文 node + gloss
// ## 關聯:typed-edge `A >> 謂詞 >> B`(內文裸文字端點)/ `[[卡]] >> 謂詞 >> [[卡]]`(卡對卡)
//
// 鐵律:結構符號(>>/←)與散文(## 摘要)不進 envelope。打標 embed/predicate_embed(預設 true)。
import type { EnvelopeEdge, EnvelopeNode } from '../types';
export interface HarvestResult {
nodes: EnvelopeNode[];
triplets: EnvelopeEdge[];
/** 端點對不齊 `## 實體` 的三元組(自檢護欄;見 endpoint-check.ts 用此 warn)。 */
unalignedEndpoints: string[];
}
interface Frontmatter {
gloss?: string;
tags?: string[];
}
/** 抽 frontmatter--- … ---)。簡單 YAML,只取 gloss / tags。 */
export function parseFrontmatter(md: string): { fm: Frontmatter; body: string } {
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(md);
if (!m) return { fm: {}, body: md };
const fm: Frontmatter = {};
for (const line of m[1].split('\n')) {
const g = /^gloss:\s*(.+)$/.exec(line.trim());
if (g) fm.gloss = g[1].replace(/^["']|["']$/g, '').trim();
}
return { fm, body: m[2] };
}
/** 取卡標題(首個 # H1)。 */
export function parseTitle(body: string): string | null {
const m = /^#\s+(.+)$/m.exec(body);
return m ? m[1].trim() : null;
}
/** 抽某 H2 段落內文(到下個 H2 或檔尾)。H3 子節(### …)仍算段內。 */
function section(body: string, heading: string): string | null {
// 不用 m 旗標(避免 $ 在每行尾命中);終止 = 下個 `\n## `(H2,非 H3)或字串尾。
const re = new RegExp(`(?:^|\\n)##\\s+${heading}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|$)`);
const m = re.exec(body);
return m ? m[1] : null;
}
/** 解析 `## 實體` 行:`- **正規名**alias1alias2)— 描述句`。 */
export function parseEntities(body: string): EnvelopeNode[] {
const sec = section(body, '實體');
if (!sec) return [];
const out: EnvelopeNode[] = [];
for (const raw of sec.split('\n')) {
const line = raw.trim();
if (!line.startsWith('-')) continue;
// - **名**aliases)— gloss 或 - **名** — gloss 或 - **名**
const m = /^-\s*\*\*(.+?)\*\*\s*(?:(.+?))?\s*(?:[—-]\s*(.+))?$/.exec(line);
if (!m) continue;
const name = m[1].trim();
// 別名分隔用全形「/」「、」(template 慣例);ASCII '/' 不切(如 arcrun/kbdb 是一個別名)。
const aliases = m[2]
? m[2].split(/[/、]/).map((s) => s.trim()).filter(Boolean)
: undefined;
const gloss = m[3]?.trim() || undefined;
const node: EnvelopeNode = { name, embed: true };
if (gloss) node.gloss = gloss;
if (aliases && aliases.length) node.aliases = aliases;
out.push(node);
}
return out;
}
/** 一條解析出的邊 + 它的兩端是否為卡對卡(原文帶 [[ ]])。 */
export interface ParsedEdge extends EnvelopeEdge {
/** subject 端原文是 [[wikilink]](卡對卡,不要求對齊 ## 實體)。 */
subjectIsCard: boolean;
objectIsCard: boolean;
}
/** 解析 typed-edge 行 `A >> 謂詞 >> B`sep 可設,預設 >>)。端點去 `[[ ]]`、`**`。 */
export function parseEdges(body: string, sep = '>>'): ParsedEdge[] {
const sec = section(body, '關聯');
if (!sec) return [];
const out: ParsedEdge[] = [];
const escSep = sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`^(.+?)\\s*${escSep}\\s*(.+?)\\s*${escSep}\\s*(.+?)$`);
for (const raw of sec.split('\n')) {
const line = raw.trim();
if (!line.startsWith('-')) continue;
const m = re.exec(line.replace(/^-\s*/, ''));
if (!m) continue;
const clean = (s: string) => s.replace(/\[\[|\]\]/g, '').replace(/\*\*/g, '').trim();
out.push({
subject: clean(m[1]),
predicate: m[2].trim(),
object: clean(m[3]),
predicate_embed: true,
subjectIsCard: /\[\[.+?\]\]/.test(m[1]),
objectIsCard: /\[\[.+?\]\]/.test(m[3]),
});
}
return out;
}
/** 採取單張卡 → nodes + triplets(含卡標題 node 的 frontmatter gloss)。 */
export function harvestCard(md: string): HarvestResult {
const { fm, body } = parseFrontmatter(md);
const title = parseTitle(body);
const nodes = parseEntities(body);
// 卡標題本身是個 nodewikilink 卡)。frontmatter gloss 描述它。
if (title && !nodes.some((n) => n.name === title)) {
const cardNode: EnvelopeNode = { name: title, embed: true };
if (fm.gloss) cardNode.gloss = fm.gloss;
nodes.unshift(cardNode);
}
const parsed = parseEdges(body);
// 卡對卡端點(原文 [[卡]])也是 graph node(被連到的卡)→ 補進 nodesembed:true,無 gloss)。
// 這樣它們對齊、且下游知道有這些卡 node。
const nodeNames = new Set(nodes.map((n) => n.name));
for (const e of parsed) {
if (e.subjectIsCard && !nodeNames.has(e.subject)) { nodeNames.add(e.subject); nodes.push({ name: e.subject, embed: true }); }
if (e.objectIsCard && !nodeNames.has(e.object)) { nodeNames.add(e.object); nodes.push({ name: e.object, embed: true }); }
}
// 端點對齊自檢(leo 壓測護欄):內文三元組端點(非卡對卡)須對得上某 node 名。
const unalignedEndpoints: string[] = [];
for (const e of parsed) {
if (!e.subjectIsCard && !nodeNames.has(e.subject))
unalignedEndpoints.push(`${e.subject}(在「${e.subject} >> ${e.predicate} >> ${e.object}」)`);
if (!e.objectIsCard && !nodeNames.has(e.object))
unalignedEndpoints.push(`${e.object}(在「${e.subject} >> ${e.predicate} >> ${e.object}」)`);
}
// 去掉 ParsedEdge 的 isCard 標記 → 純 EnvelopeEdge。
const triplets: EnvelopeEdge[] = parsed.map(({ subject, predicate, object, predicate_embed, confidence }) => ({
subject, predicate, object, predicate_embed, ...(confidence !== undefined ? { confidence } : {}),
}));
return { nodes, triplets, unalignedEndpoints };
}
-59
View File
@@ -1,59 +0,0 @@
// 編排:source → 採取(路徑A優先) / 萃取(路徑B fallback) → envelope。
//
// 每個 SourceFile 出一個 envelope(契約:一檔一 envelope)。採取優先:卡有三元組就採;
// 採不到(無 ## 關聯 / 非 template 卡)才走 extract。跨 repo 織網在更上層(weave)匯總。
import { harvestCard } from './harvest';
import { extract, type LlmCaller } from './extract';
import { buildEnvelope } from './envelope';
import type { SourceFile } from './source-adapter';
import type { Envelope } from '../types';
export interface ProcessOptions {
shallowCaller?: LlmCaller;
deepCaller?: LlmCaller;
/** 採取(路徑 A)模型標記,記進 extractor.model。預設 'local-harvest'。 */
harvestModel?: string;
}
export interface ProcessResult {
envelope: Envelope | null;
path: 'harvest' | 'extract' | 'skipped';
note?: string;
}
/** 採取結果是否「夠」(有三元組)→ 不必 fallback 到 extract。 */
function harvestSufficient(triplets: unknown[]): boolean {
return triplets.length > 0;
}
/** 處理單一來源檔 → envelope(採取優先,採不到 fallback extract)。 */
export async function processSource(file: SourceFile, opts: ProcessOptions = {}): Promise<ProcessResult> {
// 路徑 A:採取本地已建三元組+gloss。
const harvested = harvestCard(file.text);
if (harvestSufficient(harvested.triplets)) {
const envelope = buildEnvelope({
source: { uri: file.uri, content_hash: file.content_hash, commit: file.commit },
extractor: { model: opts.harvestModel ?? 'local-harvest', tier: 'shallow' },
nodes: harvested.nodes,
triplets: harvested.triplets,
});
const note = harvested.unalignedEndpoints.length
? `採取:${harvested.unalignedEndpoints.length} 端點對不齊(已留 node`
: undefined;
return { envelope, path: 'harvest', note };
}
// 路徑 B:裸原文 extract(需 shallowCaller)。
if (!opts.shallowCaller) {
return { envelope: null, path: 'skipped', note: '無本地三元組且未提供萃取模型 → 跳過' };
}
const ex = await extract(file.text, opts.shallowCaller, opts.deepCaller);
const envelope = buildEnvelope({
source: { uri: file.uri, content_hash: file.content_hash, commit: file.commit },
extractor: { model: ex.model, tier: ex.tier },
nodes: ex.nodes,
triplets: ex.triplets,
});
return { envelope, path: 'extract', note: ex.escalated ? '淺萃失敗 → 升 deep' : undefined };
}
-108
View File
@@ -1,108 +0,0 @@
// T1 SourceAdapter — 從 GitHub 拉 repo 的 MD 檔 + per-file content-hash。
//
// 鐵律:runtime 用 GitHub API 拉 repo(不開 Actions、不掛 webhook 自動同步)。
// 拉是 runtime 行為(人/refresh 發起的一次調用),不衝突 flag 紅線。
// source.uri = 'github:<owner>/<repo>@<path>'(穩定識別 = 快照鍵 + get_source 指標)。
export interface SourceFile {
/** github:owner/repo@path */
uri: string;
/** 檔內相對路徑(owner/repo 之外的部分)。 */
path: string;
/** 原始檔內容(UTF-8)。 */
text: string;
/** content_hashsha256 hex,快照鍵)。 */
content_hash: string;
/** git commit sha(可追溯,選填)。 */
commit?: string;
}
/** sha256 hex —— Workers 與 Node 18+ 皆有 crypto.subtle。 */
export async function contentHash(text: string): Promise<string> {
const data = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', data);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
/** 組 source.uri(單一真相格式,全程經此函式產,避免拼錯)。 */
export function makeSourceUri(owner: string, repo: string, path: string): string {
return `github:${owner}/${repo}@${path}`;
}
/** 解析 source.uri 回 {owner, repo, path}。null = 格式不符。 */
export function parseSourceUri(uri: string): { owner: string; repo: string; path: string } | null {
const m = /^github:([^/]+)\/([^@]+)@(.+)$/.exec(uri);
if (!m) return null;
return { owner: m[1], repo: m[2], path: m[3] };
}
export interface GitHubFetcher {
/** 列出 repo 內某路徑下的 MD 檔(遞迴)。回傳檔路徑 list。 */
listMarkdown(owner: string, repo: string, root?: string): Promise<string[]>;
/** 取單檔原文 + commit sha。 */
getFile(owner: string, repo: string, path: string): Promise<{ text: string; commit?: string }>;
}
/**
* 真實 GitHub API fetcherruntime 拉,非 Actions)。
* token 選填:公庫可不帶;私庫帶 GITHUB_TOKEN。測試走 mock,不打網路。
*/
export function makeGitHubFetcher(token?: string, fetchImpl: typeof fetch = fetch): GitHubFetcher {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'User-Agent': 'kbdb-ingest-plugin',
};
if (token) headers.Authorization = `Bearer ${token}`;
const api = 'https://api.github.com';
return {
async listMarkdown(owner, repo, root = '') {
// git/trees 遞迴:一次 API call 拿整棵樹(避免逐目錄 fan-out 流量)。
const res = await fetchImpl(`${api}/repos/${owner}/${repo}/git/trees/HEAD?recursive=1`, { headers });
if (!res.ok) throw new Error(`[github] list ${owner}/${repo}: ${res.status} ${res.statusText}`);
const body = (await res.json()) as { tree?: Array<{ path: string; type: string }> };
const prefix = root.replace(/^\/+|\/+$/g, '');
return (body.tree ?? [])
.filter((e) => e.type === 'blob' && e.path.endsWith('.md'))
.map((e) => e.path)
.filter((p) => (prefix ? p === prefix || p.startsWith(prefix + '/') : true));
},
async getFile(owner, repo, path) {
const res = await fetchImpl(`${api}/repos/${owner}/${repo}/contents/${encodeURIComponent(path).replace(/%2F/g, '/')}`, { headers });
if (!res.ok) throw new Error(`[github] get ${owner}/${repo}@${path}: ${res.status} ${res.statusText}`);
const body = (await res.json()) as { content?: string; encoding?: string; sha?: string };
const text = body.encoding === 'base64' && body.content ? decodeBase64Utf8(body.content) : (body.content ?? '');
return { text, commit: body.sha };
},
};
}
function decodeBase64Utf8(b64: string): string {
const clean = b64.replace(/\n/g, '');
const bin = atob(clean);
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
return new TextDecoder('utf-8').decode(bytes);
}
/** 拉一個 repo 路徑下所有 MD → SourceFile[](含 content_hash)。 */
export async function pullRepoMarkdown(
fetcher: GitHubFetcher,
owner: string,
repo: string,
root = '',
): Promise<SourceFile[]> {
const paths = await fetcher.listMarkdown(owner, repo, root);
const out: SourceFile[] = [];
for (const path of paths) {
const { text, commit } = await fetcher.getFile(owner, repo, path);
out.push({
uri: makeSourceUri(owner, repo, path),
path,
text,
content_hash: await contentHash(text),
commit,
});
}
return out;
}
BIN
View File
Binary file not shown.
-85
View File
@@ -1,85 +0,0 @@
// 共用型別 + envelope 契約鏡射(contracts/ingest-candidate.jsonfull 版含向量化打標)。
//
// 鐵律:ingest 純餵食器,只【打標】embed/predicate_embed + 帶 gloss/aliases
// 實際 embedding 歸 base/KBDB embed 模組讀標執行。ingest 自己不算向量。
// envelope 是 ingest↔graph 唯一耦合面(三守則:凍結契約)。
import { z } from '@hono/zod-openapi';
export interface Bindings {
ENVIRONMENT?: string;
/** graph 寫入端 base URL;空 = 未部署,POST 時誠實報 not-configured,不假綠。 */
GRAPH_BASE_URL?: string;
/** 萃取預設 tier 意圖(shallow=Haikudeep=Claude via CC)。 */
DEFAULT_EXTRACT_TIER?: 'shallow' | 'deep';
/** 拉 GitHub 私庫用(公庫可空)。走 secret put。 */
GITHUB_TOKEN?: string;
/** graph 寫入端 bearer(對應 graph 的 KBDB_INTERNAL_TOKEN)。走 secret put。 */
GRAPH_INTERNAL_TOKEN?: string;
}
export interface Variables {
partner_id: string;
}
// ── envelope 契約(full:含 ingest#1 升格的向量化打標欄位)──────────────
// graph 收件端 .strict() 追上 contractgraph#1 補對齊任務)後即收得下這些欄位。
export const EnvelopeNodeSchema = z
.object({
name: z.string().min(1),
/** 去重鍵:wikilink 卡用檔名(一卡一 node,不以出現次數重複 embed);實體用正規名。 */
id: z.string().optional(),
/** 一句話描述。base embed【名+gloss 一起】拉近同義詞。建議 deep tier 產。 */
gloss: z.string().optional(),
/** 同義詞(黃仁勳/Jensen Huang)。base 歸一成同一 node。 */
aliases: z.array(z.string()).optional(),
/** 向量化打標:此 node 要不要進向量庫。預設 true。ingest 打標,base 讀標執行。 */
embed: z.boolean().optional(),
entity_type: z.enum(['person', 'event', 'product', 'market', 'org']).optional(),
})
.strict();
export const EnvelopeEdgeSchema = z
.object({
subject: z.string().min(1),
predicate: z.string().min(1),
object: z.string().min(1),
/** 謂詞向量化打標(裸詞 embed,無描述)→ predicate_vector,支援關係過濾。預設 true。 */
predicate_embed: z.boolean().optional(),
confidence: z.number().min(0).max(1).optional(),
})
.strict();
export const EnvelopeSchema = z
.object({
source: z
.object({
/** 'github:<owner>/<repo>@<path>'= 快照鍵 + get_source 指標。 */
uri: z.string().min(1),
/** 來源檔內容 hash(快照鍵)。graph 比對同 hash → no-op。 */
content_hash: z.string().min(1),
anchor: z.string().optional(),
commit: z.string().optional(),
block_id: z.string().optional(),
})
.strict(),
extractor: z
.object({
model: z.string().min(1),
tier: z.enum(['shallow', 'deep']),
extracted_at: z.number().int().optional(),
})
.strict(),
nodes: z.array(EnvelopeNodeSchema).optional(),
triplets: z.array(EnvelopeEdgeSchema).min(1),
})
.strict();
export type EnvelopeNode = z.infer<typeof EnvelopeNodeSchema>;
export type EnvelopeEdge = z.infer<typeof EnvelopeEdgeSchema>;
export type Envelope = z.infer<typeof EnvelopeSchema>;
/** graph 領域欄位 — ingest 絕不可送(送了被 graph 422)。用於本地自檢,提早攔。 */
export const FORBIDDEN_TOP_KEYS = ['id', 'clusters', 'bridge_score', 'created_at', 'updated_at'] as const;
export const FORBIDDEN_EDGE_KEYS = ['subject_entity_type', 'object_entity_type'] as const;
-47
View File
@@ -1,47 +0,0 @@
import { describe, it, expect } from 'vitest';
import { buildEnvelope } from '../src/lib/envelope';
const base = {
source: { uri: 'github:o/r@a.md', content_hash: 'abc' },
extractor: { model: 'local-harvest', tier: 'shallow' as const },
triplets: [{ subject: 'A', predicate: 'p', object: 'B', predicate_embed: true }],
};
describe('buildEnvelope', () => {
it('組合法 envelope(含向量化打標欄位)', () => {
const env = buildEnvelope({
...base,
nodes: [{ name: 'A', gloss: 'a', aliases: ['a2'], embed: true, id: 'A' }],
});
expect(env.source.uri).toBe('github:o/r@a.md');
expect(env.nodes?.[0].embed).toBe(true);
expect(env.nodes?.[0].id).toBe('A');
expect(env.triplets[0].predicate_embed).toBe(true);
});
it('node 帶禁送欄位(bridge_score)→ strict throw(本地提早攔,不等 graph 422', () => {
expect(() => buildEnvelope({ ...base, nodes: [{ name: 'A', embed: true }] })).not.toThrow();
expect(() =>
buildEnvelope({ ...base, nodes: [{ name: 'A', bridge_score: 0.5 } as any] }),
).toThrow();
});
it('node 帶 graph 領域 record id(非去重 id)以外的禁送鍵 → strict throw', () => {
// 契約允許 nodes[].id(去重鍵);但 clusters 是 graph 領域 → strict 擋。
expect(() => buildEnvelope({ ...base, nodes: [{ name: 'A', id: 'A', embed: true }] })).not.toThrow();
expect(() => buildEnvelope({ ...base, nodes: [{ name: 'A', clusters: ['c'] } as any] })).toThrow();
});
it('禁送邊上 entity_type → strict throw', () => {
expect(() =>
buildEnvelope({
...base,
triplets: [{ subject: 'A', predicate: 'p', object: 'B', subject_entity_type: 'person' } as any],
}),
).toThrow();
});
it('無 triplets → throw(契約 min 1', () => {
expect(() => buildEnvelope({ ...base, triplets: [] })).toThrow();
});
});
-58
View File
@@ -1,58 +0,0 @@
import { describe, it, expect } from 'vitest';
import { extract, parseExtractJson, type LlmCaller } from '../src/lib/extract';
const GOOD_JSON = JSON.stringify({
nodes: [
{ name: '原子筆記', gloss: '一個不可再分論點的記錄單元' },
{ name: '傳統筆記', gloss: '多主題混雜的記錄' },
],
triplets: [{ subject: '原子筆記', predicate: '對立於', object: '傳統筆記', confidence: 0.9 }],
});
function caller(model: string, out: string | (() => Promise<string>)): LlmCaller {
return { model, call: typeof out === 'string' ? async () => out : out };
}
describe('parseExtractJson', () => {
it('解析 fenced JSON + 打標 embed/predicate_embed', () => {
const g = parseExtractJson('```json\n' + GOOD_JSON + '\n```');
expect(g.triplets[0].predicate_embed).toBe(true);
expect(g.nodes[0].embed).toBe(true);
expect(g.triplets[0].confidence).toBe(0.9);
});
it('無 triplets → throw', () => {
expect(() => parseExtractJson(JSON.stringify({ nodes: [], triplets: [] }))).toThrow();
});
});
describe('extract', () => {
it('淺萃成功不升級', async () => {
const r = await extract('原文', caller('haiku', GOOD_JSON));
expect(r.tier).toBe('shallow');
expect(r.escalated).toBe(false);
expect(r.model).toBe('haiku');
});
it('淺萃 JSON-fail → 升 deep(升級閘)', async () => {
const r = await extract('原文', caller('haiku', 'not json at all'), caller('claude', GOOD_JSON));
expect(r.escalated).toBe(true);
expect(r.tier).toBe('deep');
expect(r.model).toBe('claude');
expect(r.triplets.length).toBe(1);
});
it('淺萃失敗且無 deep caller → throw', async () => {
await expect(extract('原文', caller('haiku', 'garbage'))).rejects.toThrow();
});
it('端點對齊護欄:模型吐對不齊端點 → 自動補進 nodes', async () => {
const skewed = JSON.stringify({
nodes: [{ name: 'A' }],
triplets: [{ subject: 'A', predicate: '連到', object: 'B(沒在 nodes' }],
});
const r = await extract('原文', caller('haiku', skewed));
// B 被自動補成 node → 端點全對齊
expect(r.nodes.some((n) => n.name === 'B(沒在 nodes')).toBe(true);
});
});
-43
View File
@@ -1,43 +0,0 @@
import { describe, it, expect } from 'vitest';
import { makeGraphClient } from '../src/lib/graph-client';
import type { Envelope } from '../src/types';
const env: Envelope = {
source: { uri: 'github:o/r@a.md', content_hash: 'abc' },
extractor: { model: 'local-harvest', tier: 'shallow' },
triplets: [{ subject: 'A', predicate: 'p', object: 'B' }],
};
function mockFetch(status: number, body: unknown): typeof fetch {
return (async () =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })) as any;
}
describe('makeGraphClient', () => {
it('GRAPH_BASE_URL 未設 → 誠實回 ok:false,不假綠、不打網路', async () => {
let called = false;
const client = makeGraphClient(undefined, undefined, (async () => {
called = true;
return new Response('{}');
}) as any);
const r = await client.postEnvelope(env);
expect(r.ok).toBe(false);
expect(r.error).toContain('未設');
expect(called).toBe(false);
});
it('200 → ok + 帶 graph 回的 {skipped,ingested,deprecated}', async () => {
const client = makeGraphClient('https://graph.example', 'tok', mockFetch(200, { skipped: false, ingested: 1, deprecated: 0 }));
const r = await client.postEnvelope(env);
expect(r.ok).toBe(true);
expect((r.body as any).ingested).toBe(1);
});
it('422 → ok:false 帶 issues(供修禁送欄位)', async () => {
const client = makeGraphClient('https://graph.example', undefined, mockFetch(422, { error: 'invalid envelope', issues: [{ path: ['bridge_score'] }] }));
const r = await client.postEnvelope(env);
expect(r.ok).toBe(false);
expect(r.status).toBe(422);
expect((r.body as any).issues).toBeDefined();
});
});
-68
View File
@@ -1,68 +0,0 @@
import { describe, it, expect } from 'vitest';
import { harvestCard, parseEntities, parseEdges, parseFrontmatter } from '../src/lib/harvest';
const CARD = `---
tags: [掛載架構, 架構設計]
gloss: ingest 在 KBDB 堆疊裡的位置。
---
# 掛載架構
← [[ingest/00-INDEX]]
## 摘要
KBDB 是三層堆疊。
## 實體
- **kbdb-ingest-plugin**(餵食器) — 最薄一層,純 POST 候選。
- **base KBDB**arcrun/kbdb/基本盤) — 最底儲存層。
## 關聯
### 內文知識關係
- kbdb-ingest-plugin >> 掛載於 >> base KBDB
### 卡片關係
- [[掛載架構]] >> 受約束於 >> [[envelope-契約]]
`;
describe('parseFrontmatter', () => {
it('抽出 gloss', () => {
const { fm, body } = parseFrontmatter(CARD);
expect(fm.gloss).toBe('ingest 在 KBDB 堆疊裡的位置。');
expect(body).toContain('# 掛載架構');
});
});
describe('parseEntities', () => {
it('解析正規名 + aliases + gloss', () => {
const { body } = parseFrontmatter(CARD);
const nodes = parseEntities(body);
expect(nodes.map((n) => n.name)).toEqual(['kbdb-ingest-plugin', 'base KBDB']);
expect(nodes[1].aliases).toEqual(['arcrun/kbdb', '基本盤']);
expect(nodes[0].gloss).toBe('最薄一層,純 POST 候選。');
expect(nodes[0].embed).toBe(true);
});
});
describe('parseEdges', () => {
it('解析 typed-edge、去 [[ ]]、標記卡對卡', () => {
const { body } = parseFrontmatter(CARD);
const edges = parseEdges(body);
expect(edges).toContainEqual({ subject: 'kbdb-ingest-plugin', predicate: '掛載於', object: 'base KBDB', predicate_embed: true, subjectIsCard: false, objectIsCard: false });
expect(edges).toContainEqual({ subject: '掛載架構', predicate: '受約束於', object: 'envelope-契約', predicate_embed: true, subjectIsCard: true, objectIsCard: true });
});
});
describe('harvestCard', () => {
it('卡標題 node 帶 frontmatter gloss、含內文 node', () => {
const r = harvestCard(CARD);
const titleNode = r.nodes.find((n) => n.name === '掛載架構');
expect(titleNode?.gloss).toBe('ingest 在 KBDB 堆疊裡的位置。');
expect(r.nodes.some((n) => n.name === 'base KBDB')).toBe(true);
expect(r.triplets.length).toBe(2);
});
it('內文端點對齊(無對不齊)', () => {
const r = harvestCard(CARD);
// kbdb-ingest-plugin / base KBDB 都在 ## 實體;卡對卡端點不要求
expect(r.unalignedEndpoints).toEqual([]);
});
});
-73
View File
@@ -1,73 +0,0 @@
import { describe, it, expect } from 'vitest';
import { makeSourceUri, parseSourceUri, contentHash, pullRepoMarkdown, type GitHubFetcher } from '../src/lib/source-adapter';
import { processSource } from '../src/lib/pipeline';
import type { LlmCaller } from '../src/lib/extract';
describe('source-adapter uri', () => {
it('makeSourceUri / parseSourceUri round-trip', () => {
const uri = makeSourceUri('uncle6me-web', 'kbdb-ingest-plugin', 'system-dev/wiki/cards/ingest/掛載架構.md');
expect(uri).toBe('github:uncle6me-web/kbdb-ingest-plugin@system-dev/wiki/cards/ingest/掛載架構.md');
expect(parseSourceUri(uri)).toEqual({
owner: 'uncle6me-web',
repo: 'kbdb-ingest-plugin',
path: 'system-dev/wiki/cards/ingest/掛載架構.md',
});
});
it('content-hash 穩定且隨內容變', async () => {
const a = await contentHash('hello');
expect(a).toBe(await contentHash('hello'));
expect(a).not.toBe(await contentHash('world'));
});
});
const HARVEST_CARD = `---
gloss: 卡標題定義。
---
# 卡A
## 實體
- **甲** — 甲的定義。
- **乙** — 乙的定義。
## 關聯
- 甲 >> 連到 >> 乙
`;
function mockFetcher(files: Record<string, string>): GitHubFetcher {
return {
async listMarkdown() {
return Object.keys(files);
},
async getFile(_o, _r, path) {
return { text: files[path], commit: 'sha1' };
},
};
}
describe('pullRepoMarkdown + processSource', () => {
it('採取路徑 A:拉檔 → harvest → envelope(不 extract', async () => {
const sources = await pullRepoMarkdown(mockFetcher({ 'cards/a.md': HARVEST_CARD }), 'o', 'r');
expect(sources.length).toBe(1);
const result = await processSource(sources[0]);
expect(result.path).toBe('harvest');
expect(result.envelope?.triplets).toEqual([{ subject: '甲', predicate: '連到', object: '乙', predicate_embed: true }]);
expect(result.envelope?.extractor.model).toBe('local-harvest');
});
it('採不到三元組 + 無萃取模型 → skipped(不假萃)', async () => {
const sources = await pullRepoMarkdown(mockFetcher({ 'plain.md': '# 純文字\n沒有三元組。' }), 'o', 'r');
const result = await processSource(sources[0]);
expect(result.path).toBe('skipped');
expect(result.envelope).toBeNull();
});
it('採不到 → fallback extract(路徑 B', async () => {
const caller: LlmCaller = {
model: 'haiku',
call: async () => JSON.stringify({ nodes: [{ name: '甲' }], triplets: [{ subject: '甲', predicate: '是', object: '乙' }] }),
};
const sources = await pullRepoMarkdown(mockFetcher({ 'plain.md': '# 純文字\n甲是乙。' }), 'o', 'r');
const result = await processSource(sources[0], { shallowCaller: caller });
expect(result.path).toBe('extract');
expect(result.envelope?.extractor.model).toBe('haiku');
});
});
-45
View File
@@ -1,45 +0,0 @@
import { describe, it, expect } from 'vitest';
import { weave, flattenForPost, type RepoEnvelopes } from '../src/lib/weave';
import type { Envelope } from '../src/types';
function env(uri: string, nodes: string[], triplets: Array<[string, string, string]>): Envelope {
return {
source: { uri, content_hash: uri },
extractor: { model: 'local-harvest', tier: 'shallow' },
nodes: nodes.map((n) => ({ name: n, embed: true })),
triplets: triplets.map(([s, p, o]) => ({ subject: s, predicate: p, object: o })),
};
}
const repos: RepoEnvelopes[] = [
{ repo: 'o/repoA', envelopes: [env('github:o/repoA@x.md', ['Arcrun', '餵食器'], [['Arcrun', '包含', '餵食器']])] },
{ repo: 'o/repoB', envelopes: [env('github:o/repoB@y.md', ['Arcrun', '圖層'], [['Arcrun', '依賴', '圖層']])] },
];
describe('weave', () => {
it('偵測跨庫橋(同名節點跨 ≥2 repo)', () => {
const r = weave(repos);
const bridge = r.bridges.find((b) => b.node === 'Arcrun');
expect(bridge?.repos).toEqual(['o/repoA', 'o/repoB']);
expect(r.totalTriplets).toBe(2);
});
it('偵測跨庫異見(同 s/o 對、不同謂詞跨 repo', () => {
const diverge: RepoEnvelopes[] = [
{ repo: 'o/repoA', envelopes: [env('github:o/repoA@x.md', ['X', 'Y'], [['X', '支持', 'Y']])] },
{ repo: 'o/repoB', envelopes: [env('github:o/repoB@y.md', ['X', 'Y'], [['X', '反對', 'Y']])] },
];
const r = weave(diverge);
expect(r.divergences.length).toBe(1);
expect(r.divergences[0].predicatesByRepo.map((p) => p.predicate).sort()).toEqual(['反對', '支持']);
});
it('flattenForPost 攤平所有 envelope(順序穩定)', () => {
expect(flattenForPost(repos).length).toBe(2);
});
it('ingest 不算 bridge_score(橋只標 repos,無分數欄位)', () => {
const r = weave(repos);
expect(r.bridges[0]).not.toHaveProperty('bridge_score');
});
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from 'vitest/config';
// ingest 純餵食器:不綁 D1/Vectorize/AI。測試走純 node + mockfetch / graph client)。
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
},
});
-24
View File
@@ -1,24 +0,0 @@
name = "kbdb-ingest-plugin"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
workers_dev = true
# KBDB-ingest 插件 = 純餵食器:GitHub 拉 + 採取/萃取 + 跨庫織網 → POST envelope 給 graph。
# 鐵律:不碰儲存(無 D1/Vectorize/AI 綁定——那些屬 base/graphingest 不直連)。
# 部署走 wrangler,繞 GitHub Actions(被 flag 教訓)。
[vars]
ENVIRONMENT = "development"
# graph 插件寫入端 base URLPOST {GRAPH_BASE_URL}/triplets/ingest)。
# 部署前用 `wrangler secret put` 或在此填,例如 https://kbdb-graph.<acct>.workers.dev
GRAPH_BASE_URL = ""
# 萃取(路徑 B)預設模型意圖。"shallow"=Haiku/Workers AI"deep"=Claude via CC。
DEFAULT_EXTRACT_TIER = "shallow"
[alias]
"zod/v3" = "zod"
"zod/v4" = "zod"
"zod/v4-mini" = "zod"
# GITHUB_TOKEN / GRAPH_INTERNAL_TOKEN / ANTHROPIC 等機敏值走 `wrangler secret put`,不寫這裡。