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 未寫入 (三元組層在該步之前已落地,未造假、未覆蓋)。
This commit is contained in:
+195
-68
@@ -1,35 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
// KBDB-ingest 薄 ops CLI — 最小可行版(walking skeleton,2026-07-03 補跑首版)
|
||||
// KBDB-ingest 薄 ops CLI — 第二版(2026-07-05,總管:整分庫完整 ingest)
|
||||
//
|
||||
// 現況(誠實記錄,見 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)。
|
||||
// 相對於首版(2026-07-03 walking skeleton,僅路徑 A + 3 張策展卡)新增:
|
||||
// 1. 路徑 B(裸原文萃取):掃 journals/*.md、pages/*.md 這類「未精耕的原始筆記」,
|
||||
// 用 Haiku 直接 extract 成 (s,p,o)+node gloss(gloss 即精耕摘要,對齊 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)。
|
||||
//
|
||||
// 預設行為(不帶 --card/--raw):路徑 A(system-dev/wiki/cards/**/*.md)
|
||||
// + 路徑 B(journals/*.md、pages/*.md,跳過空檔)=整分庫一次過。
|
||||
//
|
||||
// 用法:
|
||||
// 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
|
||||
// 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 { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
const HAIKU_MODEL = 'claude-haiku-4-5';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { cards: [] };
|
||||
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;
|
||||
}
|
||||
return out;
|
||||
@@ -37,32 +42,92 @@ function parseArgs(argv) {
|
||||
|
||||
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]');
|
||||
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);
|
||||
}
|
||||
|
||||
// 預設:若未指定 --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);
|
||||
}
|
||||
// --- 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_KEY,fallback `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);
|
||||
}
|
||||
if (existsSync(dir)) walk(dir);
|
||||
return out;
|
||||
}
|
||||
|
||||
const cardPaths = args.cards.length
|
||||
? args.cards.map((c) => path.join(args.notesRepo, c))
|
||||
: defaultCards(args.notesRepo);
|
||||
function defaultCards(notesRepo) {
|
||||
return walkMd(path.join(notesRepo, 'system-dev', 'wiki', 'cards'));
|
||||
}
|
||||
|
||||
if (cardPaths.length === 0) {
|
||||
console.error('找不到任何卡片可處理(system-dev/wiki/cards/ 為空,或用 --card 指定)');
|
||||
process.exit(1);
|
||||
// 路徑 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) {
|
||||
@@ -77,9 +142,29 @@ 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) {
|
||||
// contract 的 nodes[].entity_type 是 strict enum;Haiku 偶爾猜 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) 三元組,捕捉卡片講的核心關係/主張/因果鏈。
|
||||
|
||||
@@ -94,21 +179,29 @@ function extractTriples(cardText, cardTitle) {
|
||||
|
||||
卡片內容:
|
||||
${cardText}`;
|
||||
return parseHaikuJson(await callHaiku(prompt), 'Haiku(card)');
|
||||
}
|
||||
|
||||
const result = execFileSync('claude', ['-p', prompt, '--model', 'haiku'], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
// 路徑 B:裸原始筆記 → 精耕(節點 gloss 即摘要)+ 萃三元組(design §1 路徑 B)。
|
||||
async function extractFromRaw(rawText, noteTitle) {
|
||||
const prompt = `你是知識圖譜萃取器,處理「裸筆記」——這是 Logseq 日記/頁面的原始條列(尚未精耕),
|
||||
可能口語、跳躍、含個人反思。你的工作分兩步在心裡完成,只輸出最終 JSON:
|
||||
(1) 精耕:先把這則裸筆記在心裡濃縮成幾個核心概念/主張(每個概念寫一句 gloss 摘要)。
|
||||
(2) 萃取:從精耕結果萃出 (subject, predicate, object) 三元組,捕捉主張/因果/類比關係。
|
||||
|
||||
// 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;
|
||||
規則:
|
||||
- 只輸出 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) {
|
||||
@@ -123,46 +216,70 @@ async function postEnvelope(graphUrl, envelope) {
|
||||
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');
|
||||
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;
|
||||
}
|
||||
|
||||
console.log(`\n=== ${relPath} ===`);
|
||||
let extracted;
|
||||
try {
|
||||
extracted = extractTriples(content, title);
|
||||
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.error(' ✗ Haiku 沒萃出任何 triplet,跳過(不送空 envelope,contract 要求 triplets minItems 1)');
|
||||
console.log(' ↷ 無可萃取的三元組(裸筆記無知識含量或全為流水帳),跳過');
|
||||
summary.no_triplet++;
|
||||
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;
|
||||
}
|
||||
}
|
||||
sanitizeNodes(extracted.nodes);
|
||||
|
||||
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)',
|
||||
model: EXTRACTOR_MODEL,
|
||||
tier: 'shallow',
|
||||
extracted_at: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
@@ -171,6 +288,8 @@ for (const cardPath of cardPaths) {
|
||||
};
|
||||
|
||||
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));
|
||||
@@ -179,4 +298,12 @@ for (const cardPath of cardPaths) {
|
||||
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user