189 lines
6.0 KiB
Python
189 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Wiki Curator for 织忆 (MemoryWeave) — Auto knowledge curation pipeline.
|
|
|
|
Scans .md files, extracts concepts/entities/relations, writes to 织忆 via API.
|
|
Usage:
|
|
python3 %(script)s # incremental (SHA-256 diff tracked)
|
|
python3 %(script)s --dry-run # preview only
|
|
python3 %(script)s --force # re-process all files
|
|
python3 %(script)s --dir PATH # scan custom directory
|
|
"""
|
|
|
|
import hashlib, json, os, re, sys, time
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import requests
|
|
except ImportError:
|
|
print("ERROR: requests not installed. Run: uv pip install requests")
|
|
sys.exit(1)
|
|
|
|
# ── Config ──
|
|
ZHIYI_API = "http://localhost:7821"
|
|
ZHIYI_KEY = "zhiyi-dev-key-2026"
|
|
STATE_FILE = Path.home() / ".hermes" / "wiki_curator_state.json"
|
|
HEADERS = {"X-API-Key": ZHIYI_KEY, "Content-Type": "application/json"}
|
|
|
|
EXCLUDE_DIRS = frozenset({
|
|
"__pycache__", ".git", ".obsidian", ".trash", "node_modules",
|
|
"backups", ".cache", ".venv", ".npm-global",
|
|
})
|
|
|
|
MIN_FILE_CHARS = 500
|
|
|
|
def _sha256(text: str) -> str:
|
|
return hashlib.sha256(text.encode()).hexdigest()
|
|
|
|
def _load_state() -> dict:
|
|
if STATE_FILE.exists():
|
|
return json.loads(STATE_FILE.read_text())
|
|
return {}
|
|
|
|
def _save_state(state: dict):
|
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False))
|
|
|
|
def _scan_files(root: Path):
|
|
for path in root.rglob("*.md"):
|
|
if any(excl in path.parts for excl in EXCLUDE_DIRS):
|
|
continue
|
|
if path.name.startswith("_"):
|
|
continue
|
|
yield path
|
|
|
|
def _extract(text: str, filename: str):
|
|
concepts = []
|
|
entities = []
|
|
relations = []
|
|
|
|
# Headings → concepts
|
|
for m in re.finditer(r"^#{2,3}\s+(.+)", text, re.MULTILINE):
|
|
name = m.group(1).strip()
|
|
if len(name) > 3:
|
|
concepts.append({"name": name, "source": filename})
|
|
|
|
# Bold phrases → entities
|
|
for m in re.finditer(r"\*\*(.+?)\*\*", text):
|
|
name = m.group(1).strip()
|
|
if len(name) > 2 and len(concepts + entities) < 30:
|
|
entities.append({"name": name, "source": filename})
|
|
|
|
# First sentence of each paragraph as relation hint
|
|
for m in re.finditer(r"^([^#\n][^。\n]{10,}。[^。\n]*)", text, re.MULTILINE):
|
|
sentence = m.group(1).strip()
|
|
if len(relations) >= 10:
|
|
break
|
|
relations.append({"text": sentence[:200], "source": filename})
|
|
|
|
return concepts, entities, relations
|
|
|
|
def run(dry_run=False, force=False, root_dir=None):
|
|
start = time.time()
|
|
root = Path(root_dir).expanduser() if root_dir else Path.home() / "mc"
|
|
if not root.exists():
|
|
print(f"ERROR: directory not found: {root}")
|
|
return 1
|
|
|
|
state = _load_state() if not force else {}
|
|
files_processed = 0
|
|
total_concepts = 0
|
|
total_entities = 0
|
|
total_relations = 0
|
|
|
|
for path in _scan_files(root):
|
|
try:
|
|
content = path.read_text()
|
|
except Exception:
|
|
continue
|
|
|
|
if len(content) < MIN_FILE_CHARS:
|
|
continue
|
|
|
|
sha = _sha256(content)
|
|
rel_path = str(path.relative_to(root))
|
|
if not force and rel_path in state and state[rel_path] == sha:
|
|
continue
|
|
|
|
concepts, entities, relations = _extract(content, path.name)
|
|
files_processed += 1
|
|
|
|
if dry_run:
|
|
total_concepts += len(concepts)
|
|
total_entities += len(entities)
|
|
total_relations += len(relations)
|
|
state[rel_path] = sha
|
|
continue
|
|
|
|
# Write to 织忆
|
|
for c in concepts:
|
|
try:
|
|
payload = {
|
|
"agent_id": "wiki-curator",
|
|
"content": f"## {c['name']}\nFrom: {c['source']}",
|
|
"category": "wiki",
|
|
"metadata": {"source": rel_path, "concept_type": "concept"},
|
|
}
|
|
requests.post(f"{ZHIYI_API}/api/v1/commit", json=payload, headers=HEADERS, timeout=5)
|
|
total_concepts += 1
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.1)
|
|
|
|
for e in entities:
|
|
try:
|
|
payload = {
|
|
"agent_id": "wiki-curator",
|
|
"content": f"Entity: {e['name']} (from {e['source']})",
|
|
"category": "wiki",
|
|
"metadata": {"source": rel_path, "concept_type": "entity"},
|
|
}
|
|
requests.post(f"{ZHIYI_API}/api/v1/commit", json=payload, headers=HEADERS, timeout=5)
|
|
total_entities += 1
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.1)
|
|
|
|
for r in relations:
|
|
try:
|
|
payload = {
|
|
"from": path.stem[:50], "to": r["text"][:50],
|
|
"relation": "MENTIONS", "namespace": "wiki",
|
|
}
|
|
requests.post(f"{ZHIYI_API}/api/v1/graph/edge", json=payload, headers=HEADERS, timeout=5)
|
|
total_relations += 1
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.1)
|
|
|
|
state[rel_path] = sha
|
|
|
|
if files_processed % 10 == 0:
|
|
print(f" ... {files_processed} files processed", file=sys.stderr)
|
|
|
|
if not dry_run:
|
|
_save_state(state)
|
|
|
|
elapsed = time.time() - start
|
|
print(f"{'='*60}")
|
|
print(f" 📊 处理总结")
|
|
print(f" 处理文件数: {files_processed}")
|
|
print(f" 概念写入数: {total_concepts}")
|
|
print(f" 实体写入数: {total_entities}")
|
|
print(f" 关系写入数: {total_relations}")
|
|
print(f" 耗时: {elapsed:.1f}s")
|
|
print(f"{'='*60}")
|
|
print()
|
|
print("WIKI_CURATOR_OK")
|
|
return 0
|
|
|
|
if __name__ == "__main__":
|
|
dry_run = "--dry-run" in sys.argv
|
|
force = "--force" in sys.argv
|
|
root_dir = None
|
|
if "--dir" in sys.argv:
|
|
idx = sys.argv.index("--dir")
|
|
if idx + 1 < len(sys.argv):
|
|
root_dir = sys.argv[idx + 1]
|
|
sys.exit(run(dry_run=dry_run, force=force, root_dir=root_dir))
|