@@ -0,0 +1,309 @@
#!/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 <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_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 ) ) ;