Files
llm_wiki/tools/scripts/knowledge-graph.py

289 lines
9.9 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 python
# -*- coding: utf-8 -*-
"""
knowledge-graph.py — Phase 3 图存储层 + 图遍历搜索
维护 wiki/ 的 typed relationships 图。存储为 JSONtools/data/knowledge-graph.json),
支持图构建、证据链反向追溯、以及融合搜索(qmd 直接匹配 + graph 关系扩展)。
轻量级方案(Sqlite + JSON 混合):当前仅用 JSON,无需外部数据库依赖。
用法:
python tools/scripts/knowledge-graph.py build # 从 relations 重建图
python tools/scripts/knowledge-graph.py stats # 图统计
python tools/scripts/knowledge-graph.py evidence <页面> # 反向追溯证据链(caused_by/supports
python tools/scripts/knowledge-graph.py neighbors <页面> # 直接邻接节点
python tools/scripts/knowledge-graph.py search "<查询>" # 融合搜索(需 qmd
python tools/scripts/knowledge-graph.py export-dot # 输出 Graphviz DOT
"""
import argparse
import json
import re
import subprocess
import sys
from collections import deque
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
VAULT = Path(r"D:\Applications\app\kepano-obsidian-main")
WIKI = VAULT / "wiki"
GRAPH_DB = VAULT / "tools" / "data" / "knowledge-graph.json"
QMD = r'node "C:\Users\hhhh2024\AppData\Roaming\npm\node_modules\@tobilu\qmd\dist\cli\qmd.js"'
EVIDENCE_EDGES = {"caused_by", "supports", "extends"}
def read_frontmatter(fp: Path) -> dict | None:
content = fp.read_text(encoding="utf-8")
m = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not m or not yaml:
return None
try:
return yaml.safe_load(m.group(1)) or {}
except Exception:
return None
def build_graph() -> dict:
"""扫描所有 wiki 页面的 relations 字段,重建完整图"""
graph = {"nodes": [], "edges": [], "meta": {}}
seen = set()
edges_seen = set()
for fp in sorted(WIKI.glob("*.md")):
if fp.name in ("index.md", "log.md"):
continue
front = read_frontmatter(fp)
if front is None:
front = {}
name = fp.stem
if name not in seen:
graph["nodes"].append({
"id": name,
"type": front.get("type", "unknown"),
"confidence": front.get("confidence", 3),
"status": front.get("status", "active"),
})
seen.add(name)
for rel in front.get("relations", []) or []:
if not isinstance(rel, dict):
continue
target = str(rel.get("target", "")).strip("[]")
if not target or target == name:
continue
if target not in seen:
graph["nodes"].append({
"id": target,
"type": "unknown",
"confidence": 3,
"status": "unknown",
})
seen.add(target)
edge_key = (name, target, rel.get("type", "related"))
if edge_key in edges_seen:
continue
edges_seen.add(edge_key)
graph["edges"].append({
"source": name,
"target": target,
"type": rel.get("type", "related"),
"confidence": rel.get("confidence", 3),
})
graph["meta"] = {
"node_count": len(graph["nodes"]),
"edge_count": len(graph["edges"]),
"edge_types": {},
}
for e in graph["edges"]:
t = e["type"]
graph["meta"]["edge_types"][t] = graph["meta"]["edge_types"].get(t, 0) + 1
GRAPH_DB.parent.mkdir(parents=True, exist_ok=True)
GRAPH_DB.write_text(
json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8"
)
return graph
def load_graph() -> dict | None:
if not GRAPH_DB.exists():
return None
return json.loads(GRAPH_DB.read_text(encoding="utf-8"))
def adjacency(graph: dict, directed: bool = True) -> dict:
"""构建邻接表。directed=False 时加入反向边(标注 inverse_ 前缀)"""
adj: dict[str, list] = {}
for e in graph["edges"]:
adj.setdefault(e["source"], []).append((e["target"], e["type"], e.get("confidence", 3)))
if not directed:
adj.setdefault(e["target"], []).append(
(e["source"], "inverse_" + e["type"], e.get("confidence", 3))
)
return adj
def find_evidence_chain(target_page: str, max_depth: int = 5) -> list[str]:
"""从 target 反向追溯所有 caused_by/supports 关系的源头(BFS"""
graph = load_graph()
if not graph:
return []
reverse_adj: dict[str, list] = {}
for e in graph["edges"]:
reverse_adj.setdefault(e["target"], []).append((e["source"], e["type"]))
chain: list[str] = []
visited = {target_page}
dq = deque([(target_page, 0)])
while dq:
node, depth = dq.popleft()
for src, rel in reverse_adj.get(node, []):
if rel not in EVIDENCE_EDGES:
continue
if src in visited:
continue
visited.add(src)
chain.append((" " * (depth + 1)) + f"← [[{src}]] ({rel})")
if depth + 1 < max_depth:
dq.append((src, depth + 1))
return chain
def graph_expanded_search(query: str, count: int = 15, min_conf: int = 3) -> list[dict]:
"""融合搜索:qmd BM25/向量结果 → graph 关系扩展 → 去重重排序"""
cmd = f'{QMD} vsearch "{query}" -c wiki -n 10'
result = subprocess.run(cmd, capture_output=True, text=True, shell=True, timeout=30)
direct = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
graph = load_graph()
if not graph:
return [{"page": p, "relevance": "direct"} for p in direct[:count]]
adj = adjacency(graph, directed=True)
expanded = list(direct)
for page in direct:
for target, rel_type, conf in adj.get(page, []):
if target not in expanded and conf >= min_conf:
expanded.append(target)
return [{"page": p, "relevance": "direct" if p in direct else "graph"} for p in expanded[:count]]
def cmd_stats(args):
graph = load_graph()
if not graph:
print("Graph DB not found. Run 'build' first.")
return
meta = graph["meta"]
print(f"=== Knowledge Graph Stats ===")
print(f" Nodes: {meta['node_count']}")
print(f" Edges: {meta['edge_count']}")
print(f"\n Edge type breakdown:")
for t, c in sorted(meta["edge_types"].items(), key=lambda x: -x[1]):
print(f" {t}: {c}")
typed_nodes = sum(1 for n in graph["nodes"] if n["type"] != "unknown")
print(f"\n Typed nodes: {typed_nodes} / {meta['node_count']}")
def cmd_evidence(args):
chain = find_evidence_chain(args.page)
print(f"=== Evidence chain for [[{args.page}]] ===")
if not chain:
print(" No evidence edges (caused_by/supports/extends) found.")
return
for line in chain:
print(f" {line}")
print(f"\n Total upstream evidence: {len(chain)}")
def cmd_neighbors(args):
graph = load_graph()
if not graph:
print("Graph DB not found. Run 'build' first.")
return
found = False
for e in graph["edges"]:
if e["source"] == args.page:
print(f" --{e['type']}--> [[{e['target']}]] (conf={e.get('confidence', 3)})")
found = True
for e in graph["edges"]:
if e["target"] == args.page:
print(f" <--{e['type']}-- [[{e['source']}]] (conf={e.get('confidence', 3)})")
found = True
if not found:
print(f" [[{args.page}]] has no relations in the graph.")
def cmd_search(args):
print(f"=== Fusion search: {args.query} ===")
results = graph_expanded_search(args.query, count=args.count)
direct = [r for r in results if r["relevance"] == "direct"]
graph_hits = [r for r in results if r["relevance"] == "graph"]
if direct:
print(f"\nDirect matches ({len(direct)}):")
for r in direct:
print(f" [[{r['page']}]]")
if graph_hits:
print(f"\nGraph-expanded (via relations):")
for r in graph_hits:
print(f" [[{r['page']}]]")
print(f"\nTotal: {len(results)}")
def cmd_export_dot(args):
graph = load_graph()
if not graph:
print("Graph DB not found. Run 'build' first.")
return
print("digraph wiki {")
print(' rankdir=LR;')
for n in graph["nodes"]:
print(f' "{n["id"]}";')
for e in graph["edges"]:
print(f' "{e["source"]}" -> "{e["target"]}" [label="{e["type"]}"];')
print("}")
def main():
parser = argparse.ArgumentParser(description="Phase 3 knowledge graph storage + traversal")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("build", help="Rebuild graph from relations")
sub.add_parser("stats", help="Print graph statistics")
p_ev = sub.add_parser("evidence", help="Trace evidence chain (caused_by/supports)")
p_ev.add_argument("page", help="Target page name")
p_nb = sub.add_parser("neighbors", help="Direct neighbors of a page")
p_nb.add_argument("page", help="Page name")
p_se = sub.add_parser("search", help="Fusion search (qmd + graph expansion)")
p_se.add_argument("query", help="Search query")
p_se.add_argument("--count", type=int, default=15)
sub.add_parser("export-dot", help="Export Graphviz DOT format")
args = parser.parse_args()
if args.command == "build":
graph = build_graph()
m = graph["meta"]
print(f"Graph built: {m['node_count']} nodes, {m['edge_count']} edges")
print(f"Saved to {GRAPH_DB}")
elif args.command == "stats":
cmd_stats(args)
elif args.command == "evidence":
cmd_evidence(args)
elif args.command == "neighbors":
cmd_neighbors(args)
elif args.command == "search":
cmd_search(args)
elif args.command == "export-dot":
cmd_export_dot(args)
if __name__ == "__main__":
main()