#!/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 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 --graph-url // [--card ]... [--raw ]... [--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 --graph-url [--card ]... [--raw ]... [--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_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); } 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 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) 三元組,捕捉卡片講的核心關係/主張/因果鏈。 規則: - 只輸出 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));