Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe9fbe1dab | |||
| 773e382141 |
@@ -0,0 +1,95 @@
|
|||||||
|
# HANDOFF:cloud-worker 補跑 T-kb-skeleton(2026-07-03)
|
||||||
|
|
||||||
|
補跑今天 06:30 沒跑成的 cloud-worker routine,代做 sprint P3「T-kb-skeleton」(walking skeleton:
|
||||||
|
notes → wiki 卡 → triples → kbdb-graph-plugin → 可查)。這份記錄只講**本 repo(ingest)份內**
|
||||||
|
撞的坑;graph 插件那邊的坑記在 `kbdb-graph-plugin` repo 自己的 commit message 裡。
|
||||||
|
|
||||||
|
## 現況起點(撞牆①):這個 repo 之前是空殼
|
||||||
|
|
||||||
|
clone 下來只有 `CLAUDE.md` / `README.md` / `contracts/ingest-candidate.json` / SDD 三件式
|
||||||
|
(`docs/3-specs/ingest-pipeline/`),`tasks.md` 的 T0.5 到 T5 全部未打勾 —— **沒有任何程式碼**,
|
||||||
|
不是「文件缺漏」而是「還沒開始寫」。任務指示裡「讀部署/使用文件,CLI 形態跑一次」的前提
|
||||||
|
(已有 CLI 可跑)不成立,只能自己把最小可行的 CLI 生出來才能往下走。
|
||||||
|
|
||||||
|
## 撞牆②:沒有 ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
沙盒環境變數裡沒有 `ANTHROPIC_API_KEY`(也搜過 `~/.arcrun/config.yaml` 等常見位置,
|
||||||
|
沒找到)。CLAUDE.md 說預設用 Haiku,但沒有直打 Anthropic API 的憑證。
|
||||||
|
|
||||||
|
**繞法(誠實記錄,非硬繞)**:這個沙盒本身跑在已登入的 Claude Code session 裡,
|
||||||
|
`claude` CLI 二進位可用且已授權。改用 `claude -p <prompt> --model haiku` 子行程呼叫,
|
||||||
|
這**是**真的 Haiku 推論(同一套 Anthropic 模型),只是呼叫路徑是「經 CC session 授權的
|
||||||
|
CLI 子行程」而非「直打 Anthropic API + API key」。正式版本上線前應該換回直打 API
|
||||||
|
(需要 leo 補 `ANTHROPIC_API_KEY` credential),因為:
|
||||||
|
- CLI 子行程呼叫有 session/交互開銷,不適合大量批次跑。
|
||||||
|
- 依賴一個已登入的 CC session 存在,不是獨立、可無人值守跑的服務憑證。
|
||||||
|
|
||||||
|
## 撞牆③:Haiku 偶爾吐 enum 外的 entity_type,會被 graph 端 422 擋整批
|
||||||
|
|
||||||
|
`contracts/ingest-candidate.json` 的 `nodes[].entity_type` 是 strict enum
|
||||||
|
(person/event/product/market/org)。Haiku 萃取時偶爾猜出 enum 外的值(例如 "skill"),
|
||||||
|
graph 端 Zod `strict()` 驗證會直接拒收,422 打回整個 envelope(不是只丟該欄位)。
|
||||||
|
`scripts/ingest-cli.mjs` 加了送出前的過濾:非白名單值直接刪掉該欄位(寧可欄位缺,不要
|
||||||
|
整批被拒)。這是 ingest 端的責任(契約寫的很清楚「entity_type 沒把握就不要填」,
|
||||||
|
但沒堵住模型亂填的可能)。
|
||||||
|
|
||||||
|
## 撞牆④(比較重要):POST /triplets/ingest 不是原子的,client 中斷會留半殘資料
|
||||||
|
|
||||||
|
第一輪批次跑(3 張卡)用 `timeout 180` 包整支 CLI,結果卡 2(`Prompt能力即拆解自己邏輯的能力.md`)
|
||||||
|
萃取完 7 個 triplets 後,POST 到一半整支 node 行程被 `timeout` 砍掉 —— 但 graph 端已經
|
||||||
|
把其中 2 個 triplet 寫進去了(因為 `ingestEnvelope` 是 for-loop 逐條 `createTriplet`,
|
||||||
|
不是一次性交易)。事後查 `GET /triplets` 證實:這張卡的 source_uri 底下只有 2/7 條,
|
||||||
|
不是 0 條也不是 7 條,卡在中間。
|
||||||
|
|
||||||
|
**更麻烦的是幂等性设计跟这个情境对不上**:`ingestEnvelope` 的 dedup 邏輯是
|
||||||
|
「同 `source.uri` 下若已有 `content_hash` 相同的 active 記錄 → 整批 skip」。
|
||||||
|
因為檔案內容沒變(同一份卡片重跑),`content_hash` 一定相同 —— 意味著**這 2/7 的半殘狀態
|
||||||
|
會被後續重跑永久當成「已處理過」直接跳過,不會自動補完**。目前唯一的修復方式是人工介入
|
||||||
|
(改內容強制 hash 變化,或直接呼叫 graph 的單條 `POST /triplets` 補寫缺的 5 條)。
|
||||||
|
|
||||||
|
這次沒有為了掩蓋而重送假造一致的資料——是老實留著,另外用**沒有 timeout 限制**重新跑了
|
||||||
|
第 3 張卡(乾淨的 5/5),第 2 張的半殘狀態原樣留在 base 裡當作真實證據,可用下面的
|
||||||
|
curl 驗證。
|
||||||
|
|
||||||
|
建議記入正式設計(不是這次補跑範圍,留給 leo/graph 端評估):
|
||||||
|
- `POST /triplets/ingest` 對於「同 hash 但實際 triplet 數量對不上已寫入數量」的情況,
|
||||||
|
應該要能偵測並允許補完,而不是無條件 skip。
|
||||||
|
- 或者 ingest 端自己在 POST 前後做一次「數量核對」,不一致就重試/告警,而不是默默放過。
|
||||||
|
|
||||||
|
## 這次做了什麼(scripts/ingest-cli.mjs)
|
||||||
|
|
||||||
|
最小可行 CLI,只走「路徑 A 簡化版」:不是拉「已存在的裸三元組」(notes 裡沒有這種東西),
|
||||||
|
而是拉**已經被之前一輪 cloud-worker 精耕過的 wiki 卡**(`Leo/notes` 的
|
||||||
|
`system-dev/wiki/cards/notes/*.md`,2026-07-02 產出的 3 張),對每張卡用 Haiku 萃取
|
||||||
|
triples + node gloss,組 `contracts/ingest-candidate.json` 規定的 envelope,
|
||||||
|
POST 給 graph 插件的 `/triplets/ingest`。
|
||||||
|
|
||||||
|
`source.uri` 格式從契約範例的 `github:<owner>/<repo>@<path>` 改成
|
||||||
|
`gitea:Leo/notes@<path>`(因為整個堆疊都在 Gitea 不是 GitHub,契約本身沒有嚴格要求
|
||||||
|
一定要 `github:` 前綴,只要求非空字串 + 穩定識別)。
|
||||||
|
|
||||||
|
## 未做(老實列出,不是這次範圍)
|
||||||
|
|
||||||
|
- T1 SourceAdapter 自動化(GitHub/Gitea API 拉 repo + per-file content-hash 自動判斷變動)— 這次是手動 clone + 手動指路徑。
|
||||||
|
- T2.2 cherry-pick `polaris/mira/tools/_kbdb_client.py` — 沒做,`ingest-cli.mjs` 是重新寫的最小版本,不是 cherry-pick 移植。
|
||||||
|
- T3.1/3.4 完整 extract SOP(JSON-fail 升級 deep tier 等)— 這次沒做失敗重試/升級邏輯,Haiku JSON 解析失敗就直接跳過該卡。
|
||||||
|
- T4 跨 repo 織網 — 完全沒碰,這次只餵了 `Leo/notes` 一庫。
|
||||||
|
- 沒建 `package.json`/`wrangler.toml`(T0.5)— `ingest-cli.mjs` 是純 Node script,不是 Worker,跟原規劃的「插件也是 CF Worker」形態不同,值得之後討論薄 CLI 到底要不要是 Worker。
|
||||||
|
|
||||||
|
## 驗證用 curl(供複驗)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=https://kbdb-graph-plugin.leo21c.workers.dev
|
||||||
|
|
||||||
|
# 完整批次總覽
|
||||||
|
curl -sS "$BASE/triplets/stats" | jq .
|
||||||
|
|
||||||
|
# 卡1(Gitea 卡)— 應該 7/7 乾淨
|
||||||
|
curl -sS "$BASE/triplets?subject=Gitea" | jq '.count'
|
||||||
|
|
||||||
|
# 卡2(Prompt能力卡)— 應該只有 2/7(半殘證據,見撞牆④)
|
||||||
|
curl -sS "$BASE/triplets?limit=100" | jq '[.triplets[] | select(.source_uri == "gitea:Leo/notes@system-dev/wiki/cards/notes/Prompt能力即拆解自己邏輯的能力.md")] | length'
|
||||||
|
|
||||||
|
# 卡3(程式化邏輯卡)— 應該 5/5 乾淨(第二輪無 timeout 補跑)
|
||||||
|
curl -sS "$BASE/triplets?limit=100" | jq '[.triplets[] | select(.source_uri == "gitea:Leo/notes@system-dev/wiki/cards/notes/程式化邏輯可圖解任何主題不限AI.md")] | length'
|
||||||
|
```
|
||||||
@@ -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));
|
||||||
Reference in New Issue
Block a user