@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.
@@ -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')}")