diff --git a/docs/HANDOFF-cloud-worker-2026-07-03.md b/docs/HANDOFF-cloud-worker-2026-07-03.md new file mode 100644 index 0000000..a1acdfb --- /dev/null +++ b/docs/HANDOFF-cloud-worker-2026-07-03.md @@ -0,0 +1,95 @@ +# HANDOFF:cloud-worker 補跑 T-kb-skeleton(2026-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 --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:/@` 改成 +`gitea:Leo/notes@`(因為整個堆疊都在 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 SOP(JSON-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 . + +# 卡1(Gitea 卡)— 應該 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' +``` diff --git a/scripts/ingest-cli.mjs b/scripts/ingest-cli.mjs new file mode 100644 index 0000000..df213aa --- /dev/null +++ b/scripts/ingest-cli.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +// KBDB-ingest 薄 ops CLI — 最小可行版(walking skeleton,2026-07-03 補跑首版) +// +// 現況(誠實記錄,見 docs/HANDOFF-cloud-worker-2026-07-03.md): +// - repo 在此之前是純 SDD 骨架(tasks.md T0.5~T5 全未開始),沒有任何程式碼。 +// - 本檔只實作「打穿一條線」所需的最小路徑:路徑 A(採取本地已建 wiki 卡) +// → Haiku 萃取 triples → POST envelope 給 graph 寫入端。 +// 路徑 B(裸原文 extract)、T1 SourceAdapter 自動化、T4 跨 repo 織網都還沒做。 +// - Haiku 呼叫方式:因沙盒內沒有 ANTHROPIC_API_KEY,改用 `claude -p --model haiku` +// 子行程(沿用本機已登入的 CC session 授權),不是直打 Anthropic API。兩者最終 +// 都是真的 Haiku 推論,只是呼叫路徑不同 — 正式版本應改回直打 API(需要 credential)。 +// +// 用法: +// node scripts/ingest-cli.mjs --notes-repo --graph-url [--card ]... +// +// 範例(cloud-worker 補跑實測用法): +// node scripts/ingest-cli.mjs \ +// --notes-repo /path/to/notes-clone \ +// --graph-url https://kbdb-graph-plugin.leo21c.workers.dev + +import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; + +function parseArgs(argv) { + const out = { cards: [] }; + 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 === '--dry-run') out.dryRun = true; + } + return out; +} + +const args = parseArgs(process.argv.slice(2)); +if (!args.notesRepo || !args.graphUrl) { + console.error('用法: node ingest-cli.mjs --notes-repo --graph-url [--card ]... [--dry-run]'); + process.exit(1); +} + +// 預設:若未指定 --card,掃 system-dev/wiki/cards/**/*.md(路徑 A:已建卡) +function defaultCards(notesRepo) { + const dir = path.join(notesRepo, 'system-dev', 'wiki', 'cards'); + const out = []; + function walk(d) { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, entry.name); + if (entry.isDirectory()) walk(p); + else if (entry.name.endsWith('.md') && entry.name !== '.gitkeep') out.push(p); + } + } + if (existsSync(dir)) walk(dir); + return out; +} + +const cardPaths = args.cards.length + ? args.cards.map((c) => path.join(args.notesRepo, c)) + : defaultCards(args.notesRepo); + +if (cardPaths.length === 0) { + console.error('找不到任何卡片可處理(system-dev/wiki/cards/ 為空,或用 --card 指定)'); + process.exit(1); +} + +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'); +} + +// 呼叫 Haiku(經 `claude -p --model haiku` 子行程)萃取 triples + node gloss。 +// 輸出必須是符合 contracts/ingest-candidate.json 的 { nodes, triplets } JSON 片段。 +function extractTriples(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}`; + + const result = execFileSync('claude', ['-p', prompt, '--model', 'haiku'], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + // Haiku 有時仍會包 ```json fence,保守剝一層。 + const cleaned = result.trim().replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, ''); + let parsed; + try { + parsed = JSON.parse(cleaned); + } catch (e) { + throw new Error(`Haiku 輸出非合法 JSON:${e.message}\n原始輸出:${result.slice(0, 500)}`); + } + return parsed; +} + +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); + +for (const cardPath of cardPaths) { + const relPath = path.relative(args.notesRepo, cardPath); + const content = readFileSync(cardPath, 'utf8'); + const title = path.basename(cardPath, '.md'); + + console.log(`\n=== ${relPath} ===`); + let extracted; + try { + extracted = extractTriples(content, title); + } catch (e) { + console.error(` ✗ 萃取失敗: ${e.message}`); + continue; + } + + if (!extracted.triplets || extracted.triplets.length === 0) { + console.error(' ✗ Haiku 沒萃出任何 triplet,跳過(不送空 envelope,contract 要求 triplets minItems 1)'); + continue; + } + + // contract 的 nodes[].entity_type 是 strict enum(person/event/product/market/org)。 + // Haiku 偶爾會猜出 enum 外的值(例如 "skill");graph 端 Zod strict() 會直接 422 整批。 + // 寧可拿掉這個選填欄位也不要整個 envelope 被拒收(誠實:不確定就不填,比亂填/送違規值更對)。 + const ALLOWED_ENTITY_TYPES = new Set(['person', 'event', 'product', 'market', 'org']); + if (Array.isArray(extracted.nodes)) { + for (const n of extracted.nodes) { + if (n.entity_type && !ALLOWED_ENTITY_TYPES.has(n.entity_type)) delete n.entity_type; + } + } + + const envelope = { + source: { + // Gitea 而非 GitHub,contract 範例用 github: 前綴,這裡誠實改成 gitea: 反映實際來源。 + uri: `gitea:Leo/notes@${relPath}`, + content_hash: sha256(content), + commit, + }, + extractor: { + model: 'claude-haiku (via `claude -p --model haiku` CLI subprocess, cloud-worker 2026-07-03)', + 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`); + + 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)); +}