xiaowei-system/scripts/wiki_curator.py

568 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Wiki Curator — 自动知识策展管线 (P5)
扫描 Obsidian vault / markdown 文档,用启发式方法提取知识点,
通过织忆 API 存入结构性记忆。
Usage:
python3 wiki_curator.py # 正常扫描并写入
python3 wiki_curator.py --dry-run # 预览(不写入 API
python3 wiki_curator.py --dir /tmp/md # 指定目录
python3 wiki_curator.py --force # 忽略状态文件,全部重新处理
"""
import argparse
import hashlib
import json
import os
import re
import sys
from pathlib import Path
try:
import requests
except ImportError:
print("ERROR: 'requests' library is required. Install with: pip install requests")
sys.exit(1)
try:
import yaml
except ImportError:
print("WARNING: 'yaml' library not available; LLM mode will use env var fallback")
yaml = None
# ---------------------------------------------------------------------------
# 配置
# ---------------------------------------------------------------------------
ZHIYI_API = "http://localhost:7821"
ZHIYI_KEY = "zhiyi-dev-key-2026"
STATE_FILE = os.path.expanduser("~/.hermes/wiki_curator_state.json")
# LLM 配置
# 本地 llama-server 优先2026-08-29Agnes 兜底NewAPI 最后
AGNES_API = "https://apihub.agnes-ai.com/v1/chat/completions"
AGNES_KEY = ""
for _l in open(os.path.expanduser("~/.hermes/.env"), encoding="utf-8"):
if _l.startswith("AGNES_API_KEY=") and not _l.startswith("#"):
AGNES_KEY = _l.strip().split("=", 1)[1]
break
# 2026-09-07保持云模型(agnes)——深度知识提取(concepts/entities/relations)质量敏感,
# 4B(理解力弱)会产出低质量 JSON 污染知识库。4B 仅用于简单文本/有自动校验的任务。
LLM_API = AGNES_API if AGNES_KEY else "http://127.0.0.1:3000/v1/chat/completions"
LLM_MODEL = "agnes-2.5-flash" if AGNES_KEY else "nvidia/nemotron-3-super-120b-a12b"
# 扫描时排除的目录名称(大小写不敏感)
EXCLUDE_DIRS = {
"__pycache__", ".git", "node_modules", ".obsidian", ".trash",
"backups", ".gitlab", ".github", ".vscode", ".idea",
"venv", ".venv", "env", ".env", "__pycache__",
}
# 最小文件长度(字符数)—— 太短的文件没有足够知识量
MIN_CHARS = 500
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
def compute_sha256(content: str) -> str:
"""计算字符串的 SHA-256 摘要。"""
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def load_state() -> dict:
"""加载已处理文件的哈希状态。"""
if os.path.isfile(STATE_FILE):
try:
with open(STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
print(" [WARN] 状态文件损坏,重置为空。")
return {}
def save_state(state: dict):
"""保存处理状态到文件。"""
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
def should_exclude_dir(dirname: str) -> bool:
"""检查目录名是否在排除列表中。"""
return dirname.lower() in EXCLUDE_DIRS
def scan_md_files(scan_dir: str, force: bool, state: dict) -> list:
"""递归扫描 .md 文件,返回需要处理的 (相对路径, 绝对路径, 内容, 文件哈希)。"""
scan_path = Path(scan_dir).expanduser().resolve()
if not scan_path.is_dir():
print(f" [WARN] 目录不存在: {scan_path}")
return []
candidates = []
for root_str, dirs, files in os.walk(str(scan_path)):
# 过滤排除目录(原地修改 dirs 避免继续深入)
dirs[:] = [d for d in dirs if not should_exclude_dir(d)]
for fn in files:
if not fn.endswith(".md"):
continue
# 跳过以下划线开头的文件(草稿/私有文件)
if fn.startswith("_"):
continue
abs_path = Path(root_str) / fn
rel_path = abs_path.relative_to(scan_path)
try:
content = abs_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as e:
print(f" [WARN] 读取失败 {abs_path}: {e}")
continue
if len(content) < MIN_CHARS:
print(f" [SKIP] {rel_path} (字符数 {len(content)} < {MIN_CHARS})")
continue
file_hash = compute_sha256(content)
key = str(rel_path)
if not force and state.get(key) == file_hash:
# 文件未变更,跳过
continue
candidates.append((key, str(abs_path), content, file_hash))
return candidates
# ---------------------------------------------------------------------------
# 知识提取(启发式 / 基于关键词)
# ---------------------------------------------------------------------------
def _heuristic_extract(content: str) -> dict:
"""
从 markdown 内容中提取知识点(启发式方法)。
返回结构:
{
"concepts": [{"name": "...", "summary": "..."}],
"entities": [{"name": "...", "attributes": "..."}],
"relations": [],
}
"""
concepts = []
entities = []
# 1. 提取 heading 作为概念名称
# # 标题, ## 标题, ### 标题 等
heading_pattern = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE)
for match in heading_pattern.finditer(content):
heading_text = match.group(1).strip()
if not heading_text:
continue
# 收集该标题下的文本(直到下一个标题或文件末尾)
start_pos = match.end()
next_heading = heading_pattern.search(content, start_pos)
if next_heading:
section_content = content[start_pos:next_heading.start()].strip()
else:
section_content = content[start_pos:].strip()
# 用段落第一句作为 summary
summary = ""
if section_content:
# 取第一个非空段落作为摘要
for line in section_content.split("\n"):
line = line.strip()
if line and not line.startswith("#") and not line.startswith("-"):
summary = line[:200] # 截断
break
# 过滤掉纯符号或过短的 heading 名称
if len(heading_text) < 2:
continue
concepts.append({
"name": heading_text,
"summary": summary,
})
# 2. 提取 **粗体** 关键词作为实体
bold_pattern = re.compile(r"\*\*(.+?)\*\*")
seen_bolds = set()
for match in bold_pattern.finditer(content):
bold_text = match.group(1).strip()
if not bold_text or len(bold_text) > 80:
continue
if bold_text.lower() in seen_bolds:
continue
seen_bolds.add(bold_text.lower())
# 收集该加粗词所在的上下文(前后各 50 字符)
start = max(0, match.start() - 50)
end = min(len(content), match.end() + 50)
# 在上下文截断到行边界
context = content[start:end].replace("\n", " ").strip()
# 清理多余的空白
context = re.sub(r"\s+", " ", context)
# 标记这个实体
entities.append({
"name": bold_text,
"attributes": context[:200], # 上下文作为属性描述
})
# 3. 提取列表项中的重要短语(- 或 * 开头的行,但不包含 ** 的内容)
# 这里只处理包含中文字符或关键术语的项
list_pattern = re.compile(r"^[\s]*[-*]\s+(.+)$", re.MULTILINE)
seen_list_items = set()
for match in list_pattern.finditer(content):
item_text = match.group(1).strip()
# 跳过空项、纯链接、纯图片
if not item_text or item_text.startswith("[") or item_text.startswith("!"):
continue
# 跳过以冒号/分号结尾的短项
if len(item_text) < 4:
continue
if item_text.lower() in seen_list_items:
continue
seen_list_items.add(item_text.lower())
# 提取冒号前面的部分作为实体名,后面作为描述
if "" in item_text or ":" in item_text:
parts = re.split(r"[:]", item_text, maxsplit=1)
name = parts[0].strip()
desc = parts[1].strip() if len(parts) > 1 else ""
else:
# 如果列表中包含 **加粗**,使用加粗内容作为名称
bm = re.search(r"\*\*(.+?)\*\*", item_text)
if bm:
name = bm.group(1).strip()
# 从列表中去除加粗标记,作为描述
desc = re.sub(r"\*\*(.+?)\*\*", r"\1", item_text)
else:
name = item_text[:60]
desc = item_text
if len(name) < 2:
continue
entities.append({
"name": name,
"attributes": desc[:200],
})
return {"concepts": concepts, "entities": entities}
# ---------------------------------------------------------------------------
# LLM 知识提取
# ---------------------------------------------------------------------------
def _get_llm_key() -> str:
"""从 config.yaml 读取 NewAPI key"""
if yaml is not None:
try:
cfg_path = os.path.expanduser("~/.hermes/config.yaml")
with open(cfg_path) as f:
cfg = yaml.safe_load(f)
raw = cfg.get("providers", {}).get("newapi-local", {}).get("api_key", "")
# NewAPI 的 key 不需要 sk- 前缀
if raw.startswith("sk-"):
raw = raw[3:]
return raw
except Exception:
pass
# 回退到环境变量
return AGNES_KEY or os.environ.get("NEWAPI_API_KEY", "")
def _extract_with_llm(content: str, filepath: str) -> dict | None:
"""调用 NewAPI LLM 提取结构化知识"""
llm_key = _get_llm_key()
if not llm_key:
print(" ⚠️ No LLM API key found (check config.yaml or NEWAPI_API_KEY env)")
return None
prompt = f'''Extract concepts, entities, and relations from this document.
Return ONLY valid JSON:
{{"concepts":[{{"name":"...","summary":"..."}}],"entities":[{{"name":"...","attributes":{{}}}}],"relations":[{{"source":"...","relation":"uses|contains|depends_on|part_of|implements","target":"..."}}]}}
Document:
{content[:2000]}
'''
try:
resp = requests.post(LLM_API,
headers={"Authorization": f"Bearer {llm_key}", "Content-Type": "application/json"},
json={
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": "You are a knowledge extraction assistant. Always respond with valid JSON only."},
{"role": "user", "content": prompt}
],
"temperature": 0.1,
"max_tokens": 1000,
},
timeout=30)
data = resp.json()
if "error" in data and data["error"].get("message"):
print(f" ⚠️ LLM API error: {data['error']['message'][:60]}")
return None
choices = data.get("choices", [])
if not choices:
print(" ⚠️ LLM returned empty choices (API/model may be unavailable)")
return None
msg = choices[0].get("message", {})
text = msg.get("content", "") or ""
if not text.strip():
finish = choices[0].get("finish_reason", "")
print(f" ⚠️ LLM returned empty content (finish={finish})")
return None
# Parse JSON from response
json_match = re.search(r'\{[\s\S]*\}', text)
if json_match:
return json.loads(json_match.group())
except Exception as e:
print(f" ⚠️ LLM extraction failed: {e}")
return None
# ---------------------------------------------------------------------------
# 织忆 API 交互
# ---------------------------------------------------------------------------
def commit_memory(content: str, category: str, metadata: dict,
dry_run: bool = False) -> bool:
"""写入一条记忆到织忆。返回 True 表示成功。"""
if dry_run:
print(f" [DRY-RUN] 写入记忆: category={category}, "
f"content='{content[:80]}...'")
return True
url = f"{ZHIYI_API}/api/v1/commit"
headers = {
"X-API-Key": ZHIYI_KEY,
"Content-Type": "application/json",
}
payload = {
"agent_id": "wiki-curator",
"content": content,
"category": category,
"metadata": metadata,
}
try:
resp = requests.post(url, json=payload, headers=headers, timeout=30)
if resp.status_code in (200, 201):
return True
else:
print(f" [FAIL] HTTP {resp.status_code}: {resp.text[:200]}")
return False
except requests.exceptions.RequestException as e:
print(f" [FAIL] 请求失败: {e}")
return False
def commit_graph_edge(from_node: str, to_node: str, relation: str,
dry_run: bool = False) -> bool:
"""写入一条关系到织忆图谱。返回 True 表示成功。"""
if dry_run:
print(f" [DRY-RUN] 写入关系: {from_node} --[{relation}]--> {to_node}")
return True
url = f"{ZHIYI_API}/api/v1/graph/edge"
headers = {
"X-API-Key": ZHIYI_KEY,
"Content-Type": "application/json",
}
payload = {
"from": from_node,
"to": to_node,
"relation": relation,
"namespace": "wiki",
}
try:
resp = requests.post(url, json=payload, headers=headers, timeout=30)
if resp.status_code in (200, 201):
return True
else:
print(f" [FAIL] 关系写入 HTTP {resp.status_code}: {resp.text[:200]}")
return False
except requests.exceptions.RequestException as e:
print(f" [FAIL] 关系请求失败: {e}")
return False
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def process_file(rel_path: str, abs_path: str, content: str,
dry_run: bool = False, use_llm: bool = False) -> dict:
"""
处理单个文件:提取知识点并写入织忆。
返回统计信息。
"""
print(f"\n 📄 {rel_path}")
stats = {"concepts": 0, "entities": 0, "relations": 0}
# 提取知识
if use_llm:
method_label = "LLM"
result = _extract_with_llm(content, abs_path)
if result:
concepts = result.get("concepts", [])
entities = result.get("entities", [])
relations = result.get("relations", [])
print(f" 🤖 LLM extracted {len(concepts)} concepts, {len(entities)} entities, {len(relations)} relations")
else:
print(f" ⚠️ LLM failed for {os.path.basename(abs_path)}, falling back to heuristic")
knowledge = _heuristic_extract(content)
concepts = knowledge.get("concepts", [])
entities = knowledge.get("entities", [])
relations = knowledge.get("relations", [])
method_label = "heuristic (fallback)"
else:
method_label = "heuristic"
knowledge = _heuristic_extract(content)
concepts = knowledge.get("concepts", [])
entities = knowledge.get("entities", [])
relations = knowledge.get("relations", [])
# 添加 source 字段
for c in concepts:
c["source"] = abs_path
for e in entities:
e["source"] = abs_path
# 写入概念
for conc in concepts:
content_line = f"## {conc['name']}"
if conc.get("summary"):
content_line += f"\n{conc['summary']}"
metadata = {
"source": conc.get("source", abs_path),
"concept_type": "concept",
}
ok = commit_memory(content_line, "wiki", metadata, dry_run=dry_run)
if ok:
stats["concepts"] += 1
# 写入实体
for ent in entities:
content_line = f"### {ent['name']}"
attrs = ent.get("attributes")
if attrs:
if isinstance(attrs, dict):
attrs_str = json.dumps(attrs, ensure_ascii=False)
else:
attrs_str = str(attrs)
content_line += f"\n{attrs_str}"
metadata = {
"source": ent.get("source", abs_path),
"concept_type": "entity",
}
ok = commit_memory(content_line, "wiki", metadata, dry_run=dry_run)
if ok:
stats["entities"] += 1
# 写入关系
for rel in relations:
source_node = rel.get("source", "")
target_node = rel.get("target", "")
relation_type = rel.get("relation", "RELATED_TO").upper()
if source_node and target_node:
ok = commit_graph_edge(source_node, target_node,
relation_type, dry_run=dry_run)
if ok:
stats["relations"] += 1
# 写入简单的概念-实体关系(仅在 heuristic 且无 relations 时作为补充)
if not relations and concepts and entities:
primary_concept = concepts[0]["name"]
for ent in entities:
ok = commit_graph_edge(primary_concept, ent["name"],
"RELATED_TO", dry_run=dry_run)
if ok:
stats["relations"] += 1
return stats
def main():
parser = argparse.ArgumentParser(
description="Wiki Curator — 自动知识策展管线 (P5)"
)
parser.add_argument(
"--dry-run", action="store_true",
help="预览模式:显示将要处理的内容但不写入 API"
)
parser.add_argument(
"--dir", type=str, default="~/mc/",
help="扫描目录 (默认: ~/mc/)"
)
parser.add_argument(
"--force", action="store_true",
help="强制重新处理所有文件,忽略状态文件"
)
parser.add_argument(
"--llm", action="store_true",
help="Use LLM for concept/entity extraction (default: heuristic)"
)
args = parser.parse_args()
print("=" * 60)
print(" 织忆 Wiki Curator — 知识策展管线")
print("=" * 60)
scan_dir = os.path.expanduser(args.dir)
print(f"\n扫描目录: {scan_dir}")
if args.dry_run:
print("模式: 🔍 DRY RUN (仅预览,不写入)")
if args.force:
print("模式: 🔄 FORCE (忽略已有状态)")
# 加载状态
state = load_state() if not args.force else {}
print(f"状态文件: {STATE_FILE}")
print(f"已处理文件: {len(state)}")
# 扫描文件
candidates = scan_md_files(scan_dir, args.force, state)
print(f"\n待处理文件: {len(candidates)}")
total_stats = {"concepts": 0, "entities": 0, "relations": 0}
new_state = dict(state) # 保留旧状态,更新新处理过的
for rel_path, abs_path, content, file_hash in candidates:
stats = process_file(rel_path, abs_path, content, dry_run=args.dry_run, use_llm=args.llm)
total_stats["concepts"] += stats["concepts"]
total_stats["entities"] += stats["entities"]
total_stats["relations"] += stats.get("relations", 0)
# 更新状态(即使 dry-run 也记录,以便下次不重复扫描)
if not args.dry_run:
new_state[rel_path] = file_hash
# 保存状态
if not args.dry_run:
save_state(new_state)
print(f"\n状态已更新: {len(new_state)} 个文件记录")
# 总结
print("\n" + "=" * 60)
print(" 📊 处理总结")
print(f" 处理文件数: {len(candidates)}")
print(f" 概念写入数: {total_stats['concepts']}")
print(f" 实体写入数: {total_stats['entities']}")
print(f" 关系写入数: {total_stats['relations']}")
print("=" * 60)
print("\nWIKI_CURATOR_OK")
return 0
if __name__ == "__main__":
sys.exit(main())