@jerryjiao/knowflow 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/CONTRIBUTING.md +41 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/README.zh-CN.md +176 -0
- package/SECURITY.md +18 -0
- package/bin/knowflow.js +367 -0
- package/docs/README-CN.md +10 -0
- package/docs/architecture/system-architecture.md +150 -0
- package/docs/assets/knowflow-graph-demo.png +0 -0
- package/docs/assets/knowflow-social-preview.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/contributing.md +131 -0
- package/docs/methodology/llm-wiki-methodology.md +110 -0
- package/docs/reference/data-model.md +200 -0
- package/examples/quickstart.md +60 -0
- package/package.json +57 -0
- package/scripts/batch-ingest.cjs +455 -0
- package/scripts/bookmark_sync.sh +150 -0
- package/scripts/enrich-wiki.js +683 -0
- package/scripts/graph_builder.py +612 -0
- package/scripts/graph_relation_labeler.py +191 -0
- package/scripts/ingest.sh +162 -0
- package/scripts/pipeline.sh +73 -0
- package/scripts/tags-builder.mjs +75 -0
- package/scripts/vector-store.mjs +717 -0
- package/scripts/vector_store.py +225 -0
- package/scripts/wechat_sync.sh +199 -0
- package/scripts/wiki-auto-fix.sh +238 -0
- package/scripts/wiki-health.py +285 -0
- package/scripts/wiki-health.sh +335 -0
- package/templates/comparison.md +31 -0
- package/templates/concept.md +37 -0
- package/templates/entity.md +32 -0
- package/templates/source.md +36 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
KnowFlow — Knowledge Graph Builder (M2 Enhanced)
|
|
4
|
+
Parses wiki/ markdown files, extracts [[wikilink]] relationships,
|
|
5
|
+
generates interactive graph.html (self-contained, no server needed).
|
|
6
|
+
|
|
7
|
+
M2 改进:
|
|
8
|
+
- 支持增量更新(--incremental 模式,检查已有数据避免全量重建)
|
|
9
|
+
- 输出详细节点统计信息到控制台
|
|
10
|
+
- 确保 vis.js HTML 文件中的中文显示正常(UTF-8 BOM + 显式 charset)
|
|
11
|
+
- 同时输出 knowledge-graph.json 供外部工具消费
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
python3 graph_builder.py [--wiki-dir WIKI_ROOT] [--output OUTPUT.html] [--incremental]
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import json
|
|
20
|
+
import argparse
|
|
21
|
+
import hashlib
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
|
|
26
|
+
# ── Configuration ──────────────────────────────────────────
|
|
27
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
28
|
+
DEFAULT_WIKI_DIR = os.environ.get("KNOWFLOW_WIKI_DIR", str(PROJECT_ROOT / "wiki"))
|
|
29
|
+
DEFAULT_OUTPUT = os.environ.get("KNOWFLOW_GRAPH_OUTPUT", str(PROJECT_ROOT / "graph" / "graph.html"))
|
|
30
|
+
# 增量更新状态文件:记录每个文件的 hash,用于检测变更
|
|
31
|
+
STATE_FILE = str(Path(DEFAULT_OUTPUT).resolve().parent / '.graph-state.json')
|
|
32
|
+
|
|
33
|
+
# Node colors by category
|
|
34
|
+
CATEGORY_COLORS = {
|
|
35
|
+
"sources": {"bg": "#E3F2FD", "border": "#1565C0", "text": "#0D47A1"}, # Blue
|
|
36
|
+
"entities": {"bg": "#F3E5F5", "border": "#7B1FA2", "text": "#4A148C"}, # Purple
|
|
37
|
+
"concepts": {"bg": "#E8F5E9", "border": "#2E7D32", "text": "#1B5E20"}, # Green
|
|
38
|
+
"comparisons": {"bg": "#FFF3E0", "border": "#EF6C00", "text": "#E65100"}, # Orange
|
|
39
|
+
"_special": {"bg": "#FFEBEE", "border": "#C62828", "text": "#B71C1C"}, # Red (index/log/overview)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Node shapes by category
|
|
43
|
+
CATEGORY_SHAPES = {
|
|
44
|
+
"sources": "box",
|
|
45
|
+
"entities": "dot",
|
|
46
|
+
"concepts": "diamond",
|
|
47
|
+
"comparisons": "hexagon",
|
|
48
|
+
"_special": "star",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def log(msg):
|
|
53
|
+
"""带时间戳的日志"""
|
|
54
|
+
ts = time.strftime("%H:%M:%S")
|
|
55
|
+
print(f"[{ts}] {msg}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def file_hash(filepath: str) -> str:
|
|
59
|
+
"""计算文件的 MD5 hash 用于增量检测"""
|
|
60
|
+
try:
|
|
61
|
+
with open(filepath, 'rb') as f:
|
|
62
|
+
return hashlib.md5(f.read()).hexdigest()
|
|
63
|
+
except Exception:
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_state() -> dict:
|
|
68
|
+
"""加载上次的构建状态(文件 hash 映射)"""
|
|
69
|
+
if os.path.exists(STATE_FILE):
|
|
70
|
+
try:
|
|
71
|
+
with open(STATE_FILE, 'r', encoding='utf-8') as f:
|
|
72
|
+
return json.load(f)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def save_state(file_hashes: dict):
|
|
79
|
+
"""保存当前构建状态"""
|
|
80
|
+
state_dir = os.path.dirname(STATE_FILE)
|
|
81
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
82
|
+
with open(STATE_FILE, 'w', encoding='utf-8') as f:
|
|
83
|
+
json.dump({
|
|
84
|
+
"file_hashes": file_hashes,
|
|
85
|
+
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
86
|
+
}, f, ensure_ascii=False, indent=2)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def detect_category(filepath: str) -> str:
|
|
90
|
+
"""Detect category from file path."""
|
|
91
|
+
parts = Path(filepath).parts
|
|
92
|
+
if len(parts) >= 2:
|
|
93
|
+
parent = parts[-2]
|
|
94
|
+
if parent in CATEGORY_COLORS:
|
|
95
|
+
return parent
|
|
96
|
+
filename = Path(filepath).stem.lower()
|
|
97
|
+
if filename in ("index", "log", "overview"):
|
|
98
|
+
return "_special"
|
|
99
|
+
return "sources" # default
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def extract_title(content: str, filepath: str) -> str:
|
|
103
|
+
"""Extract title from first H1 heading."""
|
|
104
|
+
m = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
|
|
105
|
+
if m:
|
|
106
|
+
return m.group(1).strip()
|
|
107
|
+
return Path(filepath).stem.replace("-", " ").title()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def extract_summary(content: str, max_len: int = 120) -> str:
|
|
111
|
+
"""Extract first meaningful paragraph as summary."""
|
|
112
|
+
lines = content.split("\n")
|
|
113
|
+
for line in lines:
|
|
114
|
+
line = line.strip()
|
|
115
|
+
if not line or line.startswith("#") or line.startswith(">") or line.startswith("|"):
|
|
116
|
+
continue
|
|
117
|
+
if line.startswith("-") or line.startswith("*"):
|
|
118
|
+
continue
|
|
119
|
+
clean = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', line)
|
|
120
|
+
clean = re.sub(r'[*_`#]', '', clean)
|
|
121
|
+
if len(clean) > 10:
|
|
122
|
+
return clean[:max_len] + ("..." if len(clean) > max_len else "")
|
|
123
|
+
return "(无摘要)"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def extract_wikilinks(content: str) -> list[tuple[str, int]]:
|
|
127
|
+
"""Extract all [[wikilink]] targets with their positions."""
|
|
128
|
+
links = []
|
|
129
|
+
for m in re.finditer(r'\[\[([^\]]+)\]\]', content):
|
|
130
|
+
target = m.group(1).strip()
|
|
131
|
+
links.append((target, m.start()))
|
|
132
|
+
return links
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def resolve_link(target: str, all_files: dict[str, str]) -> str | None:
|
|
136
|
+
"""
|
|
137
|
+
Resolve a wikilink target to an actual file path.
|
|
138
|
+
Handles various formats:
|
|
139
|
+
- entities/karpathy → entities/karpathy.md
|
|
140
|
+
- karpathy-andrej → find karpathy-andrej.md anywhere
|
|
141
|
+
"""
|
|
142
|
+
# Direct match with .md extension
|
|
143
|
+
if target + ".md" in all_files:
|
|
144
|
+
return target + ".md"
|
|
145
|
+
|
|
146
|
+
# Already has .md
|
|
147
|
+
if target.endswith(".md") and target in all_files:
|
|
148
|
+
return target
|
|
149
|
+
|
|
150
|
+
# Partial match (filename without path)
|
|
151
|
+
basename = Path(target).stem
|
|
152
|
+
for filepath in all_files:
|
|
153
|
+
if Path(filepath).stem == basename:
|
|
154
|
+
return filepath
|
|
155
|
+
|
|
156
|
+
# Fuzzy match (contains)
|
|
157
|
+
for filepath in all_files:
|
|
158
|
+
if basename in Path(filepath).stem.lower():
|
|
159
|
+
return filepath
|
|
160
|
+
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_graph(wiki_dir: str, incremental: bool = False) -> dict:
|
|
165
|
+
"""Build graph data from wiki directory."""
|
|
166
|
+
nodes = {} # id → node data
|
|
167
|
+
edges = [] # list of edge data
|
|
168
|
+
all_files = {} # relative_path → full content
|
|
169
|
+
|
|
170
|
+
# Read all markdown files
|
|
171
|
+
wiki_path = Path(wiki_dir)
|
|
172
|
+
if not wiki_path.exists():
|
|
173
|
+
log(f"❌ Wiki directory not found: {wiki_dir}")
|
|
174
|
+
return {"nodes": [], "edges": []}
|
|
175
|
+
|
|
176
|
+
md_files = sorted(wiki_path.rglob("*.md"))
|
|
177
|
+
|
|
178
|
+
# ── Incremental check ──
|
|
179
|
+
prev_state = {}
|
|
180
|
+
current_hashes = {}
|
|
181
|
+
changed_files = set()
|
|
182
|
+
|
|
183
|
+
if incremental:
|
|
184
|
+
prev_state = load_state()
|
|
185
|
+
log(f"📋 增量模式:加载上次状态 ({len(prev_state.get('file_hashes', {}))} 个文件记录)")
|
|
186
|
+
|
|
187
|
+
for fpath in md_files:
|
|
188
|
+
rel_path = str(fpath.relative_to(wiki_path))
|
|
189
|
+
try:
|
|
190
|
+
content = fpath.read_text(encoding="utf-8")
|
|
191
|
+
all_files[rel_path] = content
|
|
192
|
+
|
|
193
|
+
# 计算并记录 hash
|
|
194
|
+
fh = file_hash(str(fpath))
|
|
195
|
+
current_hashes[rel_path] = fh
|
|
196
|
+
|
|
197
|
+
# 增量模式:检查是否有变更
|
|
198
|
+
if incremental:
|
|
199
|
+
prev_hash = prev_state.get('file_hashes', {}).get(rel_path, '')
|
|
200
|
+
if fh != prev_hash:
|
|
201
|
+
changed_files.add(rel_path)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
log(f"⚠️ Failed to read {rel_path}: {e}")
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if incremental and len(changed_files) == 0 and len(prev_state.get('file_hashes', {})) > 0:
|
|
207
|
+
log("✅ 没有检测到文件变更,跳过重建(使用 --force 可强制重建)")
|
|
208
|
+
# 返回空结果表示无需重建(调用方可以据此跳过)
|
|
209
|
+
return {"nodes": [], "edges": [], "skipped": True, "reason": "no_changes"}
|
|
210
|
+
|
|
211
|
+
if incremental:
|
|
212
|
+
log(f"🔄 检测到 {len(changed_files)} 个文件有变更,开始增量构建...")
|
|
213
|
+
|
|
214
|
+
# Build nodes
|
|
215
|
+
for rel_path, content in all_files.items():
|
|
216
|
+
category = detect_category(rel_path)
|
|
217
|
+
title = extract_title(content, rel_path)
|
|
218
|
+
summary = extract_summary(content)
|
|
219
|
+
|
|
220
|
+
color = CATEGORY_COLORS.get(category, CATEGORY_COLORS["_special"])
|
|
221
|
+
shape = CATEGORY_SHAPES.get(category, "ellipse")
|
|
222
|
+
|
|
223
|
+
node = {
|
|
224
|
+
"id": rel_path,
|
|
225
|
+
"label": title,
|
|
226
|
+
"title": f"{title}\n({rel_path})\n\n{summary}",
|
|
227
|
+
"category": category,
|
|
228
|
+
"color": {
|
|
229
|
+
"background": color["bg"],
|
|
230
|
+
"border": color["border"],
|
|
231
|
+
"highlight": {"background": color["border"], "border": color["text"]},
|
|
232
|
+
},
|
|
233
|
+
"font": {"color": color["text"], "size": 14, "face": "Inter"},
|
|
234
|
+
"shape": shape,
|
|
235
|
+
"size": 20 if category == "_special" else 16,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# Special nodes are slightly larger
|
|
239
|
+
if category == "_special":
|
|
240
|
+
node["size"] = 25
|
|
241
|
+
node["font"]["bold"] = True
|
|
242
|
+
|
|
243
|
+
nodes[rel_path] = node
|
|
244
|
+
|
|
245
|
+
# Build edges from wikilinks
|
|
246
|
+
edge_id = 0
|
|
247
|
+
seen_edges = set() # avoid duplicates
|
|
248
|
+
|
|
249
|
+
for rel_path, content in all_files.items():
|
|
250
|
+
links = extract_wikilinks(content)
|
|
251
|
+
for target, _pos in links:
|
|
252
|
+
resolved = resolve_link(target, all_files)
|
|
253
|
+
if resolved and resolved != rel_path:
|
|
254
|
+
edge_key = tuple(sorted([rel_path, resolved]))
|
|
255
|
+
if edge_key not in seen_edges:
|
|
256
|
+
seen_edges.add(edge_key)
|
|
257
|
+
edges.append({
|
|
258
|
+
"id": edge_id,
|
|
259
|
+
"from": rel_path,
|
|
260
|
+
"to": resolved,
|
|
261
|
+
"arrows": "to",
|
|
262
|
+
"color": {"color": "#90A4AE", "opacity": 0.4},
|
|
263
|
+
"width": 1.5,
|
|
264
|
+
"smooth": {"type": "curvedCW", "roundness": 0.15},
|
|
265
|
+
})
|
|
266
|
+
edge_id += 1
|
|
267
|
+
|
|
268
|
+
# Compute degree for sizing
|
|
269
|
+
degrees = defaultdict(int)
|
|
270
|
+
for e in edges:
|
|
271
|
+
degrees[e["from"]] += 1
|
|
272
|
+
degrees[e["to"]] += 1
|
|
273
|
+
|
|
274
|
+
for nid, node in nodes.items():
|
|
275
|
+
deg = degrees.get(nid, 0)
|
|
276
|
+
node["value"] = max(deg + 2, 5) # minimum size
|
|
277
|
+
node["degree"] = deg
|
|
278
|
+
|
|
279
|
+
# Category breakdown
|
|
280
|
+
categories = {}
|
|
281
|
+
for n in nodes.values():
|
|
282
|
+
cat = n["category"]
|
|
283
|
+
categories[cat] = categories.get(cat, 0) + 1
|
|
284
|
+
|
|
285
|
+
graph_data = {
|
|
286
|
+
"nodes": list(nodes.values()),
|
|
287
|
+
"edges": edges,
|
|
288
|
+
"stats": {
|
|
289
|
+
"total_nodes": len(nodes),
|
|
290
|
+
"total_edges": len(edges),
|
|
291
|
+
"files_processed": len(all_files),
|
|
292
|
+
"categories": categories,
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
# Save state after successful build
|
|
297
|
+
save_state(current_hashes)
|
|
298
|
+
|
|
299
|
+
return graph_data
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def generate_html(graph_data: dict, output_path: str):
|
|
303
|
+
"""Generate self-contained HTML with vis.js. UTF-8 ensured."""
|
|
304
|
+
stats = graph_data["stats"]
|
|
305
|
+
nodes_json = json.dumps(graph_data["nodes"], ensure_ascii=False)
|
|
306
|
+
edges_json = json.dumps(graph_data["edges"], ensure_ascii=False)
|
|
307
|
+
|
|
308
|
+
html = f'''<!DOCTYPE html>
|
|
309
|
+
<html lang="zh-CN">
|
|
310
|
+
<head>
|
|
311
|
+
<meta charset="UTF-8">
|
|
312
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
313
|
+
<title>🧠 KnowFlow Knowledge Graph</title>
|
|
314
|
+
<script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"></script>
|
|
315
|
+
<style>
|
|
316
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
317
|
+
body {{ font-family: -apple-system, 'Inter', 'SF Pro', 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #fafafa; }}
|
|
318
|
+
|
|
319
|
+
/* Header */
|
|
320
|
+
header {{
|
|
321
|
+
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
|
322
|
+
background: rgba(255,255,255,0.95); backdrop-filter: blur(10px);
|
|
323
|
+
border-bottom: 1px solid #eee; padding: 12px 24px;
|
|
324
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
325
|
+
}}
|
|
326
|
+
header h1 {{ font-size: 18px; font-weight: 600; color: #333; }}
|
|
327
|
+
header h1 span {{ opacity: 0.5; }}
|
|
328
|
+
|
|
329
|
+
/* Stats bar */
|
|
330
|
+
.stats {{ display: flex; gap: 16px; font-size: 13px; color: #666; }}
|
|
331
|
+
.stat {{ display: flex; align-items: center; gap: 4px; }}
|
|
332
|
+
.stat-dot {{ width: 8px; height: 8px; border-radius: 50%; display: inline-block; }}
|
|
333
|
+
|
|
334
|
+
/* Legend */
|
|
335
|
+
.legend {{ position: fixed; bottom: 20px; left: 20px; z-index: 100;
|
|
336
|
+
background: rgba(255,255,255,0.95); backdrop-filter: blur(10px);
|
|
337
|
+
border: 1px solid #e0e0e0; border-radius: 12px; padding: 14px 18px;
|
|
338
|
+
font-size: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }}
|
|
339
|
+
.legend-title {{ font-weight: 600; margin-bottom: 10px; color: #333; }}
|
|
340
|
+
.legend-item {{ display: flex; align-items: center; gap: 8px; margin-bottom: 6px; color: #555; }}
|
|
341
|
+
.legend-icon {{ width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; }}
|
|
342
|
+
|
|
343
|
+
/* Controls */
|
|
344
|
+
.controls {{ position: fixed; bottom: 20px; right: 20px; z-index: 100;
|
|
345
|
+
display: flex; flex-direction: column; gap: 8px; }}
|
|
346
|
+
.ctrl-btn {{
|
|
347
|
+
background: white; border: 1px solid #ddd; border-radius: 8px;
|
|
348
|
+
padding: 8px 14px; font-size: 12px; cursor: pointer; color: #555;
|
|
349
|
+
transition: all 0.15s; box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|
350
|
+
}}
|
|
351
|
+
.ctrl-btn:hover {{ border-color: #999; color: #333; }}
|
|
352
|
+
|
|
353
|
+
/* Search */
|
|
354
|
+
.search-box {{
|
|
355
|
+
position: fixed; top: 60px; right: 24px; z-index: 100;
|
|
356
|
+
background: rgba(255,255,255,0.95); backdrop-filter: blur(10px);
|
|
357
|
+
border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px;
|
|
358
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
|
|
359
|
+
}}
|
|
360
|
+
.search-box input {{ border: none; outline: none; font-size: 13px; width: 200px;
|
|
361
|
+
background: transparent; color: #333; }}
|
|
362
|
+
.search-box input::placeholder {{ color: #aaa; }}
|
|
363
|
+
|
|
364
|
+
/* Graph container */
|
|
365
|
+
#graph-container {{
|
|
366
|
+
margin-top: 56px; width: 100vw; height: calc(100vh - 56px);
|
|
367
|
+
}}
|
|
368
|
+
|
|
369
|
+
/* Info panel */
|
|
370
|
+
#info-panel {{
|
|
371
|
+
position: fixed; top: 60px; left: 24px; z-index: 100;
|
|
372
|
+
background: rgba(255,255,255,0.95); backdrop-filter: blur(10px);
|
|
373
|
+
border: 1px solid #e0e0e0; border-radius: 12px; padding: 16px 20px;
|
|
374
|
+
max-width: 320px; max-height: 40vh; overflow-y: auto;
|
|
375
|
+
font-size: 13px; line-height: 1.6; color: #444;
|
|
376
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06); display: none;
|
|
377
|
+
}}
|
|
378
|
+
#info-panel.visible {{ display: block; }}
|
|
379
|
+
#info-panel h3 {{ font-size: 15px; margin-bottom: 8px; color: #222; }}
|
|
380
|
+
#info-panel .meta {{ font-size: 11px; color: #888; margin-bottom: 10px; }}
|
|
381
|
+
#info-panel .links {{ margin-top: 10px; }}
|
|
382
|
+
#info-panel .link-item {{ display: inline-block; margin: 2px 4px 2px 0;
|
|
383
|
+
padding: 2px 8px; background: #f0f0f0; border-radius: 10px; font-size: 11px; color: #555; }}
|
|
384
|
+
|
|
385
|
+
@media (prefers-color-scheme: dark) {{
|
|
386
|
+
body {{ background: #1a1a2e; }}
|
|
387
|
+
header {{ background: rgba(26,26,46,0.95); border-color: #333; color: #eee; }}
|
|
388
|
+
header h1 {{ color: #eee; }}
|
|
389
|
+
.stats {{ color: #aaa; }}
|
|
390
|
+
.legend, .controls, .search-box, #info-panel {{
|
|
391
|
+
background: rgba(26,26,46,0.95); border-color: #333; color: #ccc; }}
|
|
392
|
+
.ctrl-btn {{ background: #252540; border-color: #444; color: #ccc; }}
|
|
393
|
+
.ctrl-btn:hover {{ border-color: #666; color: #fff; }}
|
|
394
|
+
.search-box input {{ color: #ccc; }}
|
|
395
|
+
.search-box input::placeholder {{ color: #666; }}
|
|
396
|
+
#info-panel {{ color: #ccc; }}
|
|
397
|
+
#info-panel h3 {{ color: #fff; }}
|
|
398
|
+
#info-panel .meta {{ color: #888; }}
|
|
399
|
+
#info-panel .link-item {{ background: #333; color: #aaa; }}
|
|
400
|
+
}}
|
|
401
|
+
</style>
|
|
402
|
+
</head>
|
|
403
|
+
<body>
|
|
404
|
+
|
|
405
|
+
<header>
|
|
406
|
+
<h1>🧠 KnowFlow <span>Knowledge Graph</span></h1>
|
|
407
|
+
<div class="stats">
|
|
408
|
+
<div class="stat"><span class="stat-dot" style="background:#1565C0"></span>{stats['total_nodes']} nodes</div>
|
|
409
|
+
<div class="stat"><span class="stat-dot" style="background:#90A4AE"></span>{stats['total_edges']} links</div>
|
|
410
|
+
<div class="stat"><span class="stat-dot" style="background:#4CAF50"></span>{stats['files_processed']} files</div>
|
|
411
|
+
</div>
|
|
412
|
+
</header>
|
|
413
|
+
|
|
414
|
+
<div class="search-box">
|
|
415
|
+
🔍 <input type="text" id="search-input" placeholder="Search nodes..." />
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
<div id="info-panel">
|
|
419
|
+
<h3 id="info-title">-</h3>
|
|
420
|
+
<div class="meta" id="info-meta">-</div>
|
|
421
|
+
<div id="info-summary">-</div>
|
|
422
|
+
<div class="links" id="info-links"></div>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
<div id="graph-container"></div>
|
|
426
|
+
|
|
427
|
+
<div class="legend">
|
|
428
|
+
<div class="legend-title">📂 Legend</div>
|
|
429
|
+
<div class="legend-item"><span class="legend-icon" style="background:#E3F2BD;border:2px solid #1565C0"></span> 📥 Sources</div>
|
|
430
|
+
<div class="legend-item"><span class="legend-icon" style="background:#F3E5F5;border:2px solid #7B1FA2"></span> 👤 Entities</div>
|
|
431
|
+
<div class="legend-item"><span class="legend-icon" style="background:#E8F5E9;border:2px solid #2E7D32"></span> 💡 Concepts</div>
|
|
432
|
+
<div class="legend-item"><span class="legend-icon" style="background:#FFF3E0;border:2px solid #EF6C00"></span> ⚖️ Comparisons</div>
|
|
433
|
+
<div class="legend-item"><span class="legend-icon" style="background:#FFEBEE;border:2px solid #C62828"></span> ⭐ Index / Log</div>
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
<div class="controls">
|
|
437
|
+
<button class="ctrl-btn" onclick="fitAll()">🎯 Fit all</button>
|
|
438
|
+
<button class="ctrl-btn" onclick="togglePhysics()">⚡ Physics</button>
|
|
439
|
+
<button class="ctrl-btn" onclick="clusterByCategory()">📁 Cluster by type</button>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
<script>
|
|
443
|
+
const nodes = new vis.DataSet({nodes_json});
|
|
444
|
+
const edges = new vis.DataSet({edges_json});
|
|
445
|
+
|
|
446
|
+
const container = document.getElementById('graph-container');
|
|
447
|
+
const options = {{
|
|
448
|
+
physics: {{
|
|
449
|
+
enabled: true,
|
|
450
|
+
stabilization: {{ iterations: 150, fit: true }},
|
|
451
|
+
barnesHut: {{
|
|
452
|
+
gravitationalConstant: -2500,
|
|
453
|
+
springConstant: 0.04,
|
|
454
|
+
damping: 0.09,
|
|
455
|
+
avoidOverlap: 0.5
|
|
456
|
+
}}
|
|
457
|
+
}},
|
|
458
|
+
interaction: {{
|
|
459
|
+
hover: true,
|
|
460
|
+
tooltipDelay: 200,
|
|
461
|
+
hideEdgesOnDrag: false,
|
|
462
|
+
multiselect: true,
|
|
463
|
+
}},
|
|
464
|
+
nodes: {{
|
|
465
|
+
font: {{ multi: false }},
|
|
466
|
+
borderWidthSelected: 3,
|
|
467
|
+
shadow: {{ enabled: true, color: 'rgba(0,0,0,0.2)', size: 10 }},
|
|
468
|
+
}},
|
|
469
|
+
edges: {{
|
|
470
|
+
selectionWidth: 2,
|
|
471
|
+
smooth: {{ forceDirection: 'none' }},
|
|
472
|
+
}},
|
|
473
|
+
layout: {{
|
|
474
|
+
improvedLayout: true,
|
|
475
|
+
clusterThreshold: 150,
|
|
476
|
+
}}
|
|
477
|
+
}};
|
|
478
|
+
|
|
479
|
+
const network = new vis.Network(container, {{ nodes, edges }}, options);
|
|
480
|
+
|
|
481
|
+
// ── Event handlers ──
|
|
482
|
+
network.on("selectNode", function(params) {{
|
|
483
|
+
if (params.nodes.length === 1) {{
|
|
484
|
+
const nodeId = params.nodes[0];
|
|
485
|
+
const node = nodes.get(nodeId);
|
|
486
|
+
showInfo(node);
|
|
487
|
+
}}
|
|
488
|
+
}});
|
|
489
|
+
|
|
490
|
+
network.on("deselectNode", function() {{
|
|
491
|
+
document.getElementById('info-panel').classList.remove('visible');
|
|
492
|
+
}});
|
|
493
|
+
|
|
494
|
+
function showInfo(node) {{
|
|
495
|
+
const panel = document.getElementById('info-panel');
|
|
496
|
+
document.getElementById('info-title').textContent = node.label || nodeId;
|
|
497
|
+
document.getElementById('info-meta').textContent = node.category + ' | Degree: ' + (node.degree || 0);
|
|
498
|
+
document.getElementById('info-summary').innerHTML = (node.title || '').replace(/\\n/g, '<br>');
|
|
499
|
+
panel.classList.add('visible');
|
|
500
|
+
}}
|
|
501
|
+
|
|
502
|
+
// ── Controls ──
|
|
503
|
+
function fitAll() {{ network.fit({{ animation: {{ duration: 500, easingFunction: 'easeInOutQuad' }} }}); }}
|
|
504
|
+
let physicsEnabled = true;
|
|
505
|
+
function togglePhysics() {{
|
|
506
|
+
physicsEnabled = !physicsEnabled;
|
|
507
|
+
network.setOptions({{ physics: {{ enabled: physicsEnabled }} }});
|
|
508
|
+
}}
|
|
509
|
+
function clusterByCategory() {{
|
|
510
|
+
network.fit({{ animation: {{ duration: 800 }} }});
|
|
511
|
+
}}
|
|
512
|
+
|
|
513
|
+
// ── Search ──
|
|
514
|
+
document.getElementById('search-input').addEventListener('input', function(e) {{
|
|
515
|
+
const query = e.target.value.toLowerCase().trim();
|
|
516
|
+
if (!query) {{
|
|
517
|
+
nodes.update(nodes.map(n => ({{ id: n.id, hidden: false }})));
|
|
518
|
+
return;
|
|
519
|
+
}}
|
|
520
|
+
const matched = nodes.getIds().filter(id => {{
|
|
521
|
+
const n = nodes.get(id);
|
|
522
|
+
return (n.label || '').toLowerCase().includes(query) ||
|
|
523
|
+
(n.title || '').toLowerCase().includes(query) ||
|
|
524
|
+
id.toLowerCase().includes(query);
|
|
525
|
+
}});
|
|
526
|
+
const unmatched = nodes.getIds().filter(id => !matched.includes(id));
|
|
527
|
+
nodes.update(matched.map(id => ({{ id, hidden: false }})));
|
|
528
|
+
nodes.update(unmatched.map(id => ({{ id, hidden: true }})));
|
|
529
|
+
edges.update(edges.map(e => ({{
|
|
530
|
+
id: e.id,
|
|
531
|
+
hidden: !matched.includes(e.from) || !matched.includes(e.to)
|
|
532
|
+
}})));
|
|
533
|
+
}});
|
|
534
|
+
|
|
535
|
+
// ── Auto-fit on load ──
|
|
536
|
+
network.once("stabilizationIterationsDone", function() {{
|
|
537
|
+
network.fit({{ animation: {{ duration: 600 }} }});
|
|
538
|
+
}});
|
|
539
|
+
</script>
|
|
540
|
+
</body>
|
|
541
|
+
</html>'''
|
|
542
|
+
|
|
543
|
+
# Ensure output directory exists
|
|
544
|
+
out_dir = os.path.dirname(output_path)
|
|
545
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
546
|
+
|
|
547
|
+
# Write with explicit UTF-8 encoding (no BOM for HTML — charset meta tag handles it)
|
|
548
|
+
with open(output_path, "w", encoding="utf-8", newline='\n') as f:
|
|
549
|
+
f.write(html)
|
|
550
|
+
|
|
551
|
+
# Also write raw JSON data for external tool consumption
|
|
552
|
+
json_output = output_path.replace('.html', '.json')
|
|
553
|
+
with open(json_output, "w", encoding="utf-8") as f:
|
|
554
|
+
json.dump(graph_data, f, ensure_ascii=False, indent=2)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def print_stats(stats: dict):
|
|
558
|
+
"""Print detailed node statistics to console"""
|
|
559
|
+
log("")
|
|
560
|
+
log("=" * 50)
|
|
561
|
+
log("📊 知识图谱统计信息")
|
|
562
|
+
log("=" * 50)
|
|
563
|
+
log(f" 节点总数: {stats['total_nodes']}")
|
|
564
|
+
log(f" 关系总数: {stats['total_edges']}")
|
|
565
|
+
log(f" 处理文件数: {stats['files_processed']}")
|
|
566
|
+
log("")
|
|
567
|
+
log("📂 分类详情:")
|
|
568
|
+
|
|
569
|
+
cat_labels = {
|
|
570
|
+
"sources": "📥 来源 (Sources)",
|
|
571
|
+
"entities": "👤 实体 (Entities)",
|
|
572
|
+
"concepts": "💡 概念 (Concepts)",
|
|
573
|
+
"comparisons": "⚖️ 对比 (Comparisons)",
|
|
574
|
+
"_special": "⭐ 索引/日志",
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
# Sort by count descending
|
|
578
|
+
sorted_cats = sorted(stats["categories"].items(), key=lambda x: -x[1])
|
|
579
|
+
for cat, count in sorted_cats:
|
|
580
|
+
label = cat_labels.get(cat, cat)
|
|
581
|
+
bar = "█" * count + "░" * max(0, stats['total_nodes'] - count)
|
|
582
|
+
log(f" {label:30s} {count:4d} {bar}")
|
|
583
|
+
|
|
584
|
+
log("")
|
|
585
|
+
log("=" * 50)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
if __name__ == "__main__":
|
|
589
|
+
parser = argparse.ArgumentParser(description="KnowFlow Knowledge Graph Builder (M2)")
|
|
590
|
+
parser.add_argument("--wiki-dir", default=DEFAULT_WIKI_DIR, help="Wiki root directory")
|
|
591
|
+
parser.add_argument("--output", default=DEFAULT_OUTPUT, help="Output HTML path")
|
|
592
|
+
parser.add_argument("--incremental", "-i", action="store_true",
|
|
593
|
+
help="Incremental mode: skip rebuild if no files changed")
|
|
594
|
+
args = parser.parse_args()
|
|
595
|
+
|
|
596
|
+
log("🕸️ 开始构建知识图谱...")
|
|
597
|
+
if args.incremental:
|
|
598
|
+
log("📋 增量更新模式已启用")
|
|
599
|
+
|
|
600
|
+
start_time = time.time()
|
|
601
|
+
graph_data = build_graph(args.wiki_dir, incremental=args.incremental)
|
|
602
|
+
elapsed = time.time() - start_time
|
|
603
|
+
|
|
604
|
+
# Check if skipped (no changes in incremental mode)
|
|
605
|
+
if graph_data.get("skipped"):
|
|
606
|
+
log(f"⏭️ 跳过构建({graph_data['reason']}),耗时 {elapsed:.2f}s")
|
|
607
|
+
else:
|
|
608
|
+
generate_html(graph_data, args.output)
|
|
609
|
+
print_stats(graph_data["stats"])
|
|
610
|
+
log(f"✅ 完成!耗时 {elapsed:.2f}s")
|
|
611
|
+
log(f"📄 HTML 输出: {args.output}")
|
|
612
|
+
log(f"📄 JSON 数据: {args.output.replace('.html', '.json')}")
|