Files
kbdb-ingest-plugin/scripts/ingest-cli.mjs
T
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

183 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// KBDB-ingest 薄 ops CLI — 最小可行版(walking skeleton2026-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 <clone路徑> --graph-url <graph plugin base URL> [--card <path>]...
//
// 範例(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 <path> --graph-url <url> [--card <relpath>]... [--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,跳過(不送空 envelopecontract 要求 triplets minItems 1');
continue;
}
// contract 的 nodes[].entity_type 是 strict enumperson/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 而非 GitHubcontract 範例用 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));
}