scripts/local_ingest.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""
local_ingest.py
將本地 markdown / txt 檔案(或整個目錄)轉成 XKB 知識卡片,
加入 search_index.json,供後續 sync_cards_to_wiki 使用。
這是 quickstart 和 demo mode 的共用 ingest 底層。
Usage:
python3 scripts/local_ingest.py path/to/notes/ # 整個目錄
python3 scripts/local_ingest.py path/to/file.md # 單一檔案
python3 scripts/local_ingest.py notes/ --dry-run # 預覽不寫入
python3 scripts/local_ingest.py notes/ --tag personal --category learning
python3 scripts/local_ingest.py notes/ --limit 20 # 最多 20 個檔案
Exit codes:
0 — success, 0 new cards
1 — error
2 — success, >= 1 new cards added
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from gbrain_publish import PublicationBatch
import xkb_frontmatter
from _card_prompt import gbrain_put as _gbrain_put
from _card_prompt import (
build_prompt, condense_long_content, extract_summary, find_related_context,
llm_call,
)
from xkb_frontmatter import parse as extract_frontmatter
from category_classifier import classify_content, apply_category
import xkb_paths
# ── Paths ──────────────────────────────────────────────────────────────────────
CARDS_DIR = xkb_paths.CARDS_DIR
INDEX_FILE = xkb_paths.INDEX_FILE
SUPPORTED_EXTS = {".md", ".txt", ".markdown"}
def load_index() -> dict:
if INDEX_FILE.exists():
return json.loads(INDEX_FILE.read_text(encoding="utf-8"))
return {"version": "1.1", "items": []}
def file_hash(path: Path) -> str:
"""Short hash to detect duplicate ingest of same file."""
content = path.read_bytes()
return hashlib.md5(content).hexdigest()[:8]
def card_id_for_file(path: Path) -> str:
stem = re.sub(r"[^a-zA-Z0-9\-]", "-", path.stem.lower())
stem = re.sub(r"-+", "-", stem).strip("-")[:40]
h = file_hash(path)
return f"local-{stem}-{h}"
def pmc_url_from_filename(filename: str) -> str:
"""Extract PMC ID from filename and return NCBI URL."""
m = re.match(r"(PMC\d+)", filename, re.IGNORECASE)
if m:
return f"https://www.ncbi.nlm.nih.gov/pmc/articles/{m.group(1)}/"
return ""
def collect_files(input_path: Path) -> list[Path]:
if input_path.is_file():
if input_path.suffix.lower() in SUPPORTED_EXTS:
return [input_path]
print(f"[WARN] 不支援的檔案類型:{input_path.suffix}", file=sys.stderr)
return []
if input_path.is_dir():
files = []
for ext in SUPPORTED_EXTS:
files.extend(input_path.rglob(f"*{ext}"))
return sorted(files)
print(f"[ERROR] 路徑不存在:{input_path}", file=sys.stderr)
return []
def process_file(
path: Path,
api_key: str,
existing_keys: set,
existing_items: list[dict],
force_category: str | None,
extra_tags: list[str],
dry_run: bool,
publication=None,
) -> dict | None:
card_id = card_id_for_file(path)
dedup_key = f"local|{card_id}"
saved = CARDS_DIR / f"{card_id}.md"
if saved.exists() and not dry_run:
if publication is not None:
publication.existing(saved, card_id)
return None
_gbrain_put(saved, card_id)
text = saved.read_text(encoding="utf-8")
return {"title": extract_frontmatter(text).get("title", path.stem),
"tags": [], "summary": extract_summary(text)}
if dedup_key in existing_keys:
print(f" [SKIP] 已存在:{path.name}")
return None
content = path.read_text(encoding="utf-8", errors="ignore").strip()
if not content:
print(f" [SKIP] 空檔案:{path.name}")
return None
print(f" {path.name} ({len(content)} characters)")
if dry_run:
return None
truncated = condense_long_content(content, verbose=True)
source_url = pmc_url_from_filename(path.name)
category = force_category or classify_content(
content, source_type="local-paper", current_category="research"
)["category"]
related_ctx = find_related_context(content, existing_items)
prompt = build_prompt(
content=f"Filename: {path.name}\n\n{truncated}",
card_id=card_id,
source_type="local-paper",
source_url=source_url or str(path),
category=category,
related_context=related_ctx,
)
try:
card_content = llm_call(prompt, api_key)
except Exception as e:
print(f" ❌ LLM 失敗:{e}")
return None
# Ensure id in frontmatter
if "---\n" in card_content and "id:" not in card_content:
card_content = card_content.replace("---\n", f"---\nid: {card_id}\n", 1)
card_content = apply_category(card_content, category)
if extra_tags:
# Preserve the original YAML elements, including quoted commas. Only
# encode the new values; splitting/re-encoding would change existing tags.
tags_raw = (xkb_frontmatter.get(card_content, "tags") or "").strip()
if tags_raw.startswith("[") and tags_raw.endswith("]"):
tags_raw = tags_raw[1:-1].strip()
added = ", ".join(json.dumps(tag, ensure_ascii=False) for tag in dict.fromkeys(extra_tags))
separator = ", " if tags_raw else ""
card_content = xkb_frontmatter.set_field(
card_content, "tags", f"[{tags_raw}{separator}{added}]"
)
# Save card
CARDS_DIR.mkdir(parents=True, exist_ok=True)
card_path = CARDS_DIR / f"{card_id}.md"
card_path.write_text(card_content, encoding="utf-8")
if publication is None:
_gbrain_put(card_path, card_id)
else:
publication.publish(card_path, card_id, generated=True)
print(f" 💾 cards/{card_id}.md")
# Context for subsequent cards; the search index is derived from the files.
fm = extract_frontmatter(card_content)
return {
"title": fm.get("title", path.stem),
"tags": [t.strip().strip("\"'") for t in fm.get("tags", "").strip("[]").split(",") if t.strip()],
"summary": extract_summary(card_content),
}
def main() -> int:
parser = argparse.ArgumentParser(
description="Ingest local markdown/txt files into XKB knowledge cards")
parser.add_argument("path", help="檔案或目錄路徑")
parser.add_argument("--dry-run", action="store_true", help="只列出檔案,不生成卡片")
parser.add_argument("--limit", type=int, default=0, help="最多處理 N 個檔案(0=全部)")
parser.add_argument("--category", help="覆蓋 LLM 分類(強制指定)")
parser.add_argument("--tag", dest="tags", action="append", default=[],
help="額外標籤(可多次使用)")
args = parser.parse_args()
api_key = "" # auth handled by _llm.py via openclaw CLI
input_path = Path(args.path)
files = collect_files(input_path)
if not files:
print("沒有找到可匯入的檔案。")
return 0
print(f"📂 找到 {len(files)} 個檔案")
index_data = load_index()
existing_items = index_data.get("items", [])
existing_keys = {
f"local|{item.get('relative_path','').split('/')[-1].replace('.md','')}"
for item in existing_items
if item.get("source_type") in ("local", "local-paper")
}
batch = PublicationBatch(_gbrain_put)
if not args.dry_run and not batch.ready():
return 1
attempted = 0
new_items: list[dict] = []
for path in files:
if batch.blocked:
break
saved = CARDS_DIR / f"{card_id_for_file(path)}.md"
if not args.dry_run and batch.existing(saved, card_id_for_file(path)):
continue
if args.limit and attempted >= args.limit:
continue
if f"local|{card_id_for_file(path)}" in existing_keys:
continue
attempted += 1
result = process_file(
path, api_key, existing_keys, existing_items,
args.category, args.tags, args.dry_run, batch
)
if result:
new_items.append(result)
existing_items.append(result)
existing_keys.add(f"local|{card_id_for_file(path)}")
if not args.dry_run and not batch.finish():
return 1
if new_items and not args.dry_run:
print(f"\n✅ 完成:新增 {len(new_items)} 張知識卡片")
print("💡 下一步:python3 scripts/sync_cards_to_wiki.py --apply --limit 20")
elif args.dry_run:
print(f"\n(dry-run 模式,未寫入任何卡片)")
else:
print(f"\n✅ 完成:無新增卡片(全部已存在)")
return 2 if new_items and not args.dry_run else 0
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
raise SystemExit(main())