773e382141
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>
183 lines
7.1 KiB
JavaScript
183 lines
7.1 KiB
JavaScript
#!/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 <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,跳過(不送空 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));
|
||
}
|