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

310 lines
12 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 — 第二版(2026-07-05,總管:整分庫完整 ingest
//
// 相對於首版(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)。
//
// 預設行為(不帶 --card/--raw):路徑 Asystem-dev/wiki/cards/**/*.md
// + 路徑 Bjournals/*.md、pages/*.md,跳過空檔)=整分庫一次過。
//
// 用法:
// 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: [], 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;
}
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));