@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,225 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ KnowFlow Vector Store — 基于智谱 embedding-3 的语义检索
4
+ 用法:
5
+ python3 vector_store.py build # 对所有 wiki 页面建索引
6
+ python3 vector_store.py query "AI Agent" # 语义查询
7
+ python3 vector_store.py stats # 查看索引状态
8
+ """
9
+ import os, sys, json, glob, hashlib
10
+ from pathlib import Path
11
+
12
+ PROJECT_ROOT = Path(os.environ.get("KNOWFLOW_ROOT", Path(__file__).resolve().parent.parent))
13
+ WIKI_DIR = Path(os.environ.get("KNOWFLOW_WIKI_DIR", PROJECT_ROOT / "wiki")).resolve()
14
+ INDEX_FILE = WIKI_DIR / ".vector-index.json"
15
+ EMBED_CACHE = WIKI_DIR / ".embed-cache.json"
16
+
17
+ # ====== Embedding ======
18
+ def get_embedding(text: str, api_key: str = None) -> list:
19
+ """调用智谱 embedding-3 API 获取向量"""
20
+ import urllib.request, urllib.error
21
+
22
+ key = api_key or os.environ.get("ZHIPUAI_API_KEY", "")
23
+ if not key:
24
+ # 尝试从 openclaw 配置读取
25
+ cfg_path = Path.home() / ".openclaw" / "config.yaml"
26
+ if cfg_path.exists():
27
+ import yaml
28
+ try:
29
+ cfg = yaml.safe_load(cfg_path.read_text())
30
+ key = cfg.get("zhipu", {}).get("apiKey", "") or cfg.get("providers", {}).get("zhipu", {}).get("apiKey", "")
31
+ except: pass
32
+
33
+ if not key:
34
+ print("⚠️ 未找到 ZHIPUAI_API_KEY,请设置环境变量或在 .env 中配置")
35
+ return None
36
+
37
+ url = "https://open.bigmodel.cn/api/paas/v4/embeddings"
38
+ payload = json.dumps({
39
+ "model": "embedding-3",
40
+ "input": text[:8000], # 截断过长文本
41
+ "dimensions": 1024
42
+ }).encode()
43
+
44
+ req = urllib.request.Request(url, data=payload, headers={
45
+ "Content-Type": "application/json",
46
+ "Authorization": f"Bearer {key}"
47
+ })
48
+
49
+ try:
50
+ with urllib.request.urlopen(req, timeout=30) as resp:
51
+ data = json.loads(resp.read())
52
+ return data["data"][0]["embedding"]
53
+ except Exception as e:
54
+ print(f"⚠️ Embedding API 错误: {e}")
55
+ return None
56
+
57
+ # ====== 文件扫描 ======
58
+ def scan_wiki_files() -> list:
59
+ """扫描所有 wiki markdown 文件,返回 (path, content, meta) 列表"""
60
+ files = []
61
+ for md_file in sorted(WIKI_DIR.rglob("*.md")):
62
+ # 跳过隐藏文件和特殊文件
63
+ if any(p.startswith(".") for p in md_file.parts):
64
+ continue
65
+
66
+ text = md_file.read_text(encoding="utf-8", errors="ignore")
67
+ if len(text.strip()) < 50: # 跳过太短的文件
68
+ continue
69
+
70
+ # 提取 frontmatter 之后的正文用于 embedding
71
+ body = text
72
+ if text.startswith("---"):
73
+ parts = text.split("---", 2)
74
+ if len(parts) >= 3:
75
+ body = parts[2].strip()
76
+
77
+ # 截取前 2000 字符作为 embedding 内容(标题+摘要+关键内容)
78
+ embed_text = body[:2000]
79
+
80
+ rel_path = str(md_file.relative_to(WIKI_DIR))
81
+ files.append({
82
+ "path": rel_path,
83
+ "full_path": str(md_file),
84
+ "title": md_file.stem,
85
+ "body": body,
86
+ "embed_text": embed_text,
87
+ "size": len(text),
88
+ "category": rel_path.split("/")[0] if "/" in rel_path else "root"
89
+ })
90
+
91
+ return files
92
+
93
+ # ====== Build Index ======
94
+ def build_index(force=False):
95
+ """构建/更新向量索引"""
96
+ print(f"📚 扫描 Wiki 目录: {WIKI_DIR}")
97
+ files = scan_wiki_files()
98
+ print(f"📊 找到 {len(files)} 个页面")
99
+
100
+ # 加载已有缓存
101
+ cache = {}
102
+ if EMBED_CACHE.exists() and not force:
103
+ cache = json.loads(EMBED_CACHE.read_text())
104
+
105
+ index = []
106
+ new_count = 0
107
+ cache_count = 0
108
+
109
+ for i, f in enumerate(files):
110
+ # 用文件路径+大小+修改时间做 hash 判断是否需要重新 embedding
111
+ file_hash = hashlib.md5(f"{f['path']}:{f['size']}".encode()).hexdigest()
112
+
113
+ if file_hash in cache and not force:
114
+ index.append({**f, "embedding": cache[file_hash], "_hash": file_hash})
115
+ cache_count += 1
116
+ else:
117
+ print(f" [{i+1}/{len(files)}] Embedding: {f['path']} ...", end=" ", flush=True)
118
+ emb = get_embedding(f["embed_text"])
119
+ if emb:
120
+ f["embedding"] = emb
121
+ f["_hash"] = file_hash
122
+ index.append(f)
123
+ cache[file_hash] = emb
124
+ new_count += 1
125
+ print("✅")
126
+ else:
127
+ print("❌ 跳过")
128
+
129
+ # 每 10 个保存一次缓存
130
+ if (i + 1) % 20 == 0:
131
+ EMBED_CACHE.write_text(json.dumps(cache))
132
+
133
+ # 保存最终结果
134
+ EMBED_CACHE.write_text(json.dumps(cache))
135
+ INDEX_FILE.write_text(json.dumps(index, ensure_ascii=False, indent=2))
136
+
137
+ print(f"\n✅ 索引构建完成!")
138
+ print(f" 新增: {new_count} | 缓存: {cache_count} | 总计: {len(index)}")
139
+ print(f" 索引文件: {INDEX_FILE} ({INDEX_FILE.stat().st_size / 1024:.1f} KB)")
140
+ print(f" 缓存文件: {EMBED_CACHE} ({EMBED_CACHE.stat().st_size / 1024:.1f} KB)")
141
+
142
+ # ====== Query ======
143
+ def query(text: str, top_k: int = 5, category_filter: str = None) -> list:
144
+ """语义查询,返回最相关的页面"""
145
+ if not INDEX_FILE.exists():
146
+ print("❌ 索引不存在,请先运行: python3 vector_store.py build")
147
+ return []
148
+
149
+ index = json.loads(INDEX_FILE.read_text())
150
+ if not index:
151
+ print("❌ 索引为空")
152
+ return []
153
+
154
+ print(f"🔍 查询: \"{text}\"")
155
+ query_emb = get_embedding(text)
156
+ if not query_emb:
157
+ return []
158
+
159
+ # 余弦相似度
160
+ def cosine_similarity(a, b):
161
+ dot = sum(x * y for x, y in zip(a, b))
162
+ norm_a = sum(x * x for x in a) ** 0.5
163
+ norm_b = sum(x * x for x in b) ** 0.5
164
+ if norm_a == 0 or norm_b == 0: return 0
165
+ return dot / (norm_a * norm_b)
166
+
167
+ results = []
168
+ for item in index:
169
+ if category_filter and item.get("category") != category_filter:
170
+ continue
171
+ score = cosine_similarity(query_emb, item["embedding"])
172
+ results.append({**item, "score": round(score, 4)})
173
+
174
+ results.sort(key=lambda x: x["score"], reverse=True)
175
+ top = results[:top_k]
176
+
177
+ print(f"\n📋 Top {len(top)} 结果:\n")
178
+ for r in top:
179
+ cat_emoji = {"sources":"📄","entities":"🏷️","concepts":"💡","topics":"📑","root":"📁"}.get(r.get("category"), "📄")
180
+ print(f" {cat_emoji} [{r['score']:.3f}] {r['path']}")
181
+ print(f" ({r['size']} chars | {r['category']})")
182
+ # 显示匹配到的关键词上下文
183
+ body_preview = r.get("body", "")[:200].replace("\n", " ")
184
+ print(f" 预览: {body_preview}...")
185
+ print()
186
+
187
+ return top
188
+
189
+ # ====== Stats ======
190
+ def show_stats():
191
+ """显示索引统计"""
192
+ if not INDEX_FILE.exists():
193
+ print("❌ 索引不存在"); return
194
+
195
+ index = json.loads(INDEX_FILE.read_text())
196
+ categories = {}
197
+ for item in index:
198
+ c = item.get("category", "root")
199
+ categories[c] = categories.get(c, 0) + 1
200
+
201
+ print(f"📊 Vector Store 统计:")
202
+ print(f" 总页面数: {len(index)}")
203
+ print(f" 索引大小: {INDEX_FILE.stat().st_size / 1024:.1f} KB")
204
+ print(f" 缓存大小: {EMBED_CACHE.stat().st_size / 1024:.1f} KB" if EMBED_CACHE.exists() else "")
205
+ print(f"\n 按分类:")
206
+ for c, cnt in sorted(categories.items(), key=lambda x: -x[1]):
207
+ emoji = {"sources":"📄","entities":"🏷️","concepts":"💡","topics":"📑","root":"📁"}.get(c, "📁")
208
+ print(f" {emoji} {c}: {cnt}")
209
+
210
+ # ====== Main ======
211
+ if __name__ == "__main__":
212
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "stats"
213
+
214
+ if cmd == "build":
215
+ build_index("--force" in sys.argv)
216
+ elif cmd == "query":
217
+ q = " ".join(sys.argv[2:])
218
+ if not q:
219
+ print("用法: python3 vector_store.py query \"搜索内容\"")
220
+ else:
221
+ query(q)
222
+ elif cmd == "stats":
223
+ show_stats()
224
+ else:
225
+ print(__doc__)
@@ -0,0 +1,199 @@
1
+ #!/bin/bash
2
+ # jerry-wiki wechat article sync
3
+ # Searches for WeChat articles via Brave Search and ingests them
4
+ # Usage: bash scripts/wechat_sync.sh [--dry-run]
5
+ #
6
+ # Config: scripts/.wechat-accounts.json (search queries + accounts)
7
+ set -euo pipefail
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ WIKI_ROOT="${KNOWFLOW_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
11
+ RAW_BASE="${KNOWFLOW_RAW_DIR:-$WIKI_ROOT/raw}"
12
+ RAW_DIR="$RAW_BASE/wechat"
13
+ STATE_FILE="$WIKI_ROOT/.wechat-state.json"
14
+ TIMESTAMP=$(date +%Y-%m-%d-%H%M)
15
+ ACCOUNTS_FILE="$SCRIPT_DIR/.wechat-accounts.json"
16
+
17
+ mkdir -p "$RAW_DIR"
18
+
19
+ # ── Load config ────────────────────────────────────────
20
+ if [ ! -f "$ACCOUNTS_FILE" ]; then
21
+ cat > "$ACCOUNTS_FILE" << 'EOF'
22
+ {
23
+ "accounts": [
24
+ {"name": "宝玉", "keywords": ["宝玉", "dotey", "Claude Code"]},
25
+ {"name": "科技爱好者", "keywords": ["AI 工具", "AI 编程", "开源"]}
26
+ ],
27
+ "searchQueries": [
28
+ "AI编程工具 site:mp.weixin.qq.com",
29
+ "Claude Code 教程 site:mp.weixin.qq.com",
30
+ "AI Agent 开发 site:mp.weixin.qq.com",
31
+ "OpenClaw 使用 site:mp.weixin.qq.com"
32
+ ],
33
+ "maxResults": 10,
34
+ "maxAgeDays": 7
35
+ }
36
+ EOF
37
+ echo "📝 Created default $ACCOUNTS_FILE (edit to customize)"
38
+ fi
39
+
40
+ CONFIG=$(cat "$ACCOUNTS_FILE")
41
+
42
+ # Read queries into temp file (handle spaces in queries)
43
+ TMP_QUERIES=$(mktemp)
44
+ echo "$CONFIG" | python3 -c "
45
+ import json, sys
46
+ data = json.load(sys.stdin)
47
+ for q in data.get('searchQueries', []):
48
+ print(q)
49
+ " > "$TMP_QUERIES" 2>/dev/null || true
50
+
51
+ MAX_RESULTS=$(echo "$CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin).get('maxResults',5))" 2>/dev/null || echo 5)
52
+
53
+ if [ ! -s "$TMP_QUERIES" ]; then
54
+ rm -f "$TMP_QUERIES"
55
+ echo "❌ No search queries configured in $ACCOUNTS_FILE"
56
+ exit 1
57
+ fi
58
+
59
+ DRY_RUN=false
60
+ case "${1:-}" in --dry-run) DRY_RUN=true ;; esac
61
+
62
+ # ── State tracking ─────────────────────────────────────
63
+ SEEN_URLS=$(python3 -c "
64
+ import json
65
+ state = {}
66
+ try:
67
+ with open('$STATE_FILE') as f:
68
+ state = json.load(f)
69
+ except: pass
70
+ for url in state.get('seenUrls', []):
71
+ print(url)
72
+ " 2>/dev/null || true)
73
+
74
+ _TEMP_FILES=("$TMP_QUERIES")
75
+ cleanup() { rm -f "${_TEMP_FILES[@]:-}" 2>/dev/null || true; }
76
+ trap cleanup EXIT INT TERM
77
+
78
+ # ── Search & Fetch ──────────────────────────────────────
79
+ echo "📱 WeChat Article Sync — $TIMESTAMP"
80
+ echo ""
81
+
82
+ NEW_COUNT=0
83
+ TMPJSON=$(mktemp)
84
+ _TEMP_FILES+=("$TMPJSON")
85
+
86
+ while IFS= read -r query; do
87
+ [ -z "$query" ] && continue
88
+ echo "🔍 Searching: $query"
89
+
90
+ # Try brave-search first (reliable), fallback to exa
91
+ OK=false
92
+ if command -v mcporter &>/dev/null; then
93
+ mcporter call 'brave-search.brave_web_search' query="$query" count="$MAX_RESULTS" > "$TMPJSON" 2>/dev/null && OK=true
94
+ if [ "$OK" = false ]; then
95
+ mcporter call 'exa.web_search_exa' query="$query" numResults="$MAX_RESULTS" includeDomains='["mp.weixin.qq.com"]' > "$TMPJSON" 2>/dev/null && OK=true
96
+ fi
97
+ fi
98
+
99
+ if [ "$OK" = false ]; then
100
+ echo " ⚠️ All search providers failed, skipping query"
101
+ continue
102
+ fi
103
+
104
+ # Parse results and download new articles
105
+ # brave-search outputs plain text (Title/Description/URL blocks), exa outputs JSON
106
+ python3 - "$TMPJSON" "$RAW_DIR" "$TIMESTAMP" "$STATE_FILE" << 'PYEOF'
107
+ import json, sys, os, subprocess, re
108
+ from datetime import datetime
109
+
110
+ tmpjson, raw_dir, timestamp, state_file = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
111
+
112
+ with open(tmpjson) as f:
113
+ raw = f.read()
114
+
115
+ results = []
116
+
117
+ # Try JSON first (exa format)
118
+ try:
119
+ start = raw.index('{')
120
+ end = raw.rindex('}') + 1
121
+ data = json.loads(raw[start:end])
122
+ results = data.get('results', []) if isinstance(data, dict) else []
123
+ if not results and isinstance(data, list):
124
+ results = data
125
+ except Exception:
126
+ pass
127
+
128
+ # Fallback: parse plain text format (brave-search)
129
+ if not results:
130
+ # Split by "Title: " pattern to find result blocks
131
+ blocks = re.split(r'(?=^Title:\s)', raw, flags=re.MULTILINE)
132
+ for block in blocks[1:]: # Skip header if any
133
+ title_m = re.search(r'^Title:\s*(.+)', block, re.MULTILINE)
134
+ url_m = re.search(r'^URL:\s*(https?://\S+)', block, re.MULTILINE)
135
+ desc_m = re.search(r'^Description:\s*(.+?)(?=^URL:|^Title:|$)', block, re.MULTILINE | re.DOTALL)
136
+ if url_m:
137
+ results.append({
138
+ 'url': url_m.group(1).strip(),
139
+ 'title': (title_m.group(1).strip() if title_m else 'unknown'),
140
+ 'description': (desc_m.group(1).strip()[:500] if desc_m else ''),
141
+ })
142
+
143
+ if not results:
144
+ print(" ℹ️ No results")
145
+ sys.exit(0)
146
+
147
+ state = {"seenUrls": [], "lastSyncAt": ""}
148
+ if os.path.exists(state_file):
149
+ try:
150
+ with open(state_file) as f:
151
+ state = json.load(f)
152
+ except: pass
153
+ seen = set(state.get("seenUrls", []))
154
+
155
+ new_count = 0
156
+ for r in results[:10]:
157
+ url = r.get("url", "")
158
+ if not url or url in seen or "mp.weixin.qq.com" not in url:
159
+ continue
160
+
161
+ title = str(r.get("title", "unknown"))
162
+ published = str(r.get("publishedDate", ""))[:10]
163
+
164
+ safe_title = re.sub(r'[/\\|:*?<>]', '-', title)[:60]
165
+ filename = f"{timestamp}-{published}-{safe_title}.md"
166
+ filepath = os.path.join(str(raw_dir), filename)
167
+
168
+ if os.path.exists(filepath):
169
+ continue
170
+
171
+ try:
172
+ result = subprocess.run(
173
+ ["curl", "-sL", "--max-time", "20", f"https://r.jina.ai/{url}"],
174
+ capture_output=True, text=True, timeout=25
175
+ )
176
+ content = result.stdout.strip()
177
+ if len(content) < 100:
178
+ content = f"# {title}\n\n> Source: {url}\n> Published: {published}\n\n{r.get('text', '')[:2000]}"
179
+
180
+ with open(filepath, 'w') as f:
181
+ f.write(content)
182
+ seen.add(url)
183
+ new_count += 1
184
+ print(f" + {filename}")
185
+ except Exception as e:
186
+ print(f" ✗ Failed: {title} ({e})")
187
+
188
+ state["seenUrls"] = list(seen)[-500:]
189
+ state["lastSyncAt"] = datetime.now().isoformat()
190
+ with open(state_file, 'w') as f:
191
+ json.dump(state, f, indent=2, ensure_ascii=False)
192
+
193
+ print(f"\n 📊 {new_count} new articles saved")
194
+ PYEOF
195
+
196
+ done < "$TMP_QUERIES"
197
+
198
+ echo ""
199
+ echo "✅ WeChat sync complete. Run pipeline.sh to ingest → build."
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env bash
2
+ # knowflow wiki-auto-fix — 自动修复 health check 发现的问题
3
+ # Usage: bash scripts/wiki-auto-fix.sh [--wiki-dir <path>] [--dry-run]
4
+ #
5
+ # 在 health check 之前运行,自动:
6
+ # 1. 清理空链接 [[entities/,]] [[concepts/,]]
7
+ # 2. 创建缺失的 entity/concept 文件
8
+ # 3. 补充过小的文件(<100B)
9
+ # 4. 关联孤儿页(添加引用)
10
+
11
+ set -euo pipefail
12
+
13
+ # ── Config ──────────────────────────────────────────────
14
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
15
+ WIKI_ROOT="${KNOWFLOW_ROOT:-${WIKI_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}}"
16
+ WIKI_DIR="${KNOWFLOW_WIKI_DIR:-$WIKI_ROOT/wiki}"
17
+ DRY_RUN=false
18
+ MIN_SIZE=100
19
+
20
+ while [ $# -gt 0 ]; do
21
+ case "$1" in
22
+ --dry-run) DRY_RUN=true; shift ;;
23
+ --wiki-root) WIKI_ROOT="$2"; WIKI_DIR="$WIKI_ROOT/wiki"; shift 2 ;;
24
+ --wiki-dir) WIKI_DIR="$2"; shift 2 ;;
25
+ --min-size) MIN_SIZE="$2"; shift 2 ;;
26
+ *) echo "Unknown: $1" >&2; exit 1 ;;
27
+ esac
28
+ done
29
+
30
+ # Portable in-place sed: BSD (macOS) needs -i '' with the backup suffix arg,
31
+ # GNU sed takes the script directly after -i.
32
+ sed_i() {
33
+ if [[ "$(uname)" == "Darwin" ]]; then
34
+ sed -i '' "$@"
35
+ else
36
+ sed -i "$@"
37
+ fi
38
+ }
39
+
40
+ FIXED_LINKS=0
41
+ CREATED_FILES=0
42
+ PADDED_FILES=0
43
+
44
+ run_cmd() {
45
+ if $DRY_RUN; then
46
+ echo "[DRY] $*"
47
+ else
48
+ "$@"
49
+ fi
50
+ }
51
+
52
+ echo "🔧 Wiki Auto-Fix"
53
+ echo "================"
54
+
55
+ # ════════════════════════════════════════════════
56
+ # Fix 1: 清理空链接 [[entities/,]] [[concepts/,]]
57
+ # ════════════════════════════════════════════════
58
+ echo ""
59
+ echo "[1/4] 清理空链接..."
60
+
61
+ while IFS= read -r -d '' file; do
62
+ if grep -q '\[\[entities\/,\]\]\|\[\[concepts\/,\]\]' "$file" 2>/dev/null; then
63
+ local_count=$(grep -c '\[\[entities\/,\]\]\|\[\[concepts\/,\]\]' "$file" 2>/dev/null || echo 0)
64
+ run_cmd sed_i '/\[\[entities\/,\]\]/d' "$file"
65
+ run_cmd sed_i '/\[\[concepts\/,\]\]/d' "$file"
66
+ FIXED_LINKS=$((FIXED_LINKS + local_count))
67
+ rel="${file#$WIKI_DIR/}"
68
+ echo " ✏️ $rel (清理 $local_count 个空链接)"
69
+ fi
70
+ done < <(find "$WIKI_DIR" -name '*.md' -not -path '*/.understand-anything/*' -print0)
71
+ echo " 共清理 $FIXED_LINKS 个空链接"
72
+
73
+ # ════════════════════════════════════════════════
74
+ # Fix 2: 创建缺失的 entity/concept 文件
75
+ # ════════════════════════════════════════════════
76
+ echo ""
77
+ echo "[2/4] 创建缺失实体/概念文件..."
78
+
79
+ LINKS_TMP="$(mktemp)"
80
+ while IFS= read -r -d '' file; do
81
+ grep -o '\[\[entities\/[^]]*\]\]\|\[\[concepts\/[^]]*\]\]' "$file" 2>/dev/null \
82
+ | sed 's/\[\[//;s/\]\]//' \
83
+ | sed 's/|.*//' \
84
+ | grep -v '^[[:space:]]*$' \
85
+ | awk '!seen[$0]++' >> "$LINKS_TMP" || true
86
+ done < <(find "$WIKI_DIR" -name '*.md' -not -path '*/.understand-anything/*' -print0)
87
+
88
+ while IFS= read -r link; do
89
+ [ -z "$link" ] && continue
90
+ target_file="$WIKI_DIR/${link}.md"
91
+ if [ ! -f "$target_file" ]; then
92
+ name=$(basename "$link")
93
+ display_name=$(echo "$name" | sed 's/-/ /g')
94
+
95
+ case "${link%%/*}" in
96
+ entities) type_label="实体" ;;
97
+ concepts) type_label="概念" ;;
98
+ topics) type_label="主题" ;;
99
+ *) type_label="待分类" ;;
100
+ esac
101
+
102
+ run_cmd mkdir -p "$(dirname "$target_file")"
103
+ # NOTE: redirections run before `run_cmd` is called, so we must not route the
104
+ # heredoc through run_cmd in dry-run mode — the `>` would truncate/create the
105
+ # file even when the command itself is skipped. Render to a temp buffer and
106
+ # write once outside dry-run.
107
+ content=$(printf '# %s\n\n## 类型\n%s\n\n## 描述\n(由 wiki-auto-fix 自动创建,待补充详细信息)\n' "$display_name" "$type_label")
108
+ if $DRY_RUN; then
109
+ echo "[DRY] create $target_file"
110
+ else
111
+ printf '%s' "$content" > "$target_file"
112
+ fi
113
+ CREATED_FILES=$((CREATED_FILES + 1))
114
+ echo " ➕ ${link}.md (${type_label})"
115
+ fi
116
+ done < "$LINKS_TMP"
117
+ rm -f "$LINKS_TMP"
118
+ echo " 共创建 $CREATED_FILES 个缺失文件"
119
+
120
+ # ════════════════════════════════════════════════
121
+ # Fix 3: 补充过小文件
122
+ # ════════════════════════════════════════════════
123
+ echo ""
124
+ echo "[3/4] 补充过小文件 (<${MIN_SIZE}B)..."
125
+
126
+ while IFS= read -r -d '' file; do
127
+ size=$(wc -c < "$file" | tr -d ' ')
128
+ rel="${file#$WIKI_DIR/}"
129
+ name_base=$(basename "$rel" .md)
130
+
131
+ case "$rel" in
132
+ entities/*) suffix="\n\n## 备注\n此页面由 wiki-auto-fix 自动扩充。原始内容仅 ${size} 字节。" ;;
133
+ concepts/*) suffix="\n\n## 延伸阅读\n此概念页面由 wiki-auto-fix 自动扩充。原始内容仅 ${size} 字节。" ;;
134
+ topics/*) suffix="\n\n## 子主题\n此主题页面由 wiki-auto-fix 自动扩充。原始内容仅 ${size} 字节。" ;;
135
+ *) suffix="\n\n---\n*此文件由 wiki-auto-fix 标记为过小(原 ${size}B)*" ;;
136
+ esac
137
+
138
+ # Same dry-run caveat as Fix 2: route the write explicitly, never through a
139
+ # redirection that the shell would apply before run_cmd decides to skip.
140
+ if $DRY_RUN; then
141
+ echo "[DRY] pad $rel (+$(printf "$suffix" | wc -c | tr -d ' ') bytes)"
142
+ else
143
+ printf '%b%s\n' "$(cat "$file")" "$suffix" > "$file"
144
+ fi
145
+ PADDED_FILES=$((PADDED_FILES + 1))
146
+ echo " 📝 $rel (${size}B → 已补充)"
147
+ done < <(find "$WIKI_DIR" -name '*.md' -not -path '*/.understand-anything/*' -size +0 -size -${MIN_SIZE}c -print0)
148
+
149
+ echo " 共补充 $PADDED_FILES 个文件"
150
+
151
+ # ════════════════════════════════════════════════
152
+ # Fix 4: 关联孤儿页(用 Python 避免 bash [[ 冲突)
153
+ # ════════════════════════════════════════════════
154
+ echo ""
155
+ echo "[4/4] 处理孤儿页..."
156
+
157
+ python3 - "$WIKI_DIR" "$DRY_RUN" << 'PYEOF'
158
+ import os, sys, re
159
+
160
+ wiki_dir = sys.argv[1]
161
+ dry_run = sys.argv[2].lower() == "true"
162
+ index_path = os.path.join(wiki_dir, "index.md")
163
+ skip_prefixes = ("index.md", "overview.md", "topics.md", "log.md",
164
+ ".vector", ".embed", "comparisons/", "sources/")
165
+
166
+ referenced = set()
167
+ for root, dirs, files in os.walk(wiki_dir):
168
+ if ".understand-anything" in root:
169
+ continue
170
+ for f in files:
171
+ if not f.endswith(".md"):
172
+ continue
173
+ fpath = os.path.join(root, f)
174
+ try:
175
+ with open(fpath, "r", errors="replace") as fh:
176
+ for line in fh:
177
+ for m in re.finditer(r'\[\[([^]|]+?)(?:\|[^]]+)?\]\]', line):
178
+ target = m.group(1).strip()
179
+ if not target:
180
+ continue
181
+ if target.startswith(("entities/", "concepts/", "topics/")):
182
+ tpath = os.path.join(wiki_dir, target + ".md")
183
+ elif target.startswith("/"):
184
+ tpath = os.path.join(wiki_dir, target.lstrip("/") + ".md")
185
+ else:
186
+ continue
187
+ referenced.add(os.path.normpath(tpath))
188
+ except Exception:
189
+ pass
190
+
191
+ orphan_count = 0
192
+ fixed_count = 0
193
+ for root, dirs, files in os.walk(wiki_dir):
194
+ if ".understand-anything" in root:
195
+ continue
196
+ for f in files:
197
+ if not f.endswith(".md"):
198
+ continue
199
+ rel = os.path.relpath(os.path.join(root, f), wiki_dir)
200
+ if rel.startswith(skip_prefixes) or rel == "index.md":
201
+ continue
202
+ fpath = os.path.join(root, f)
203
+ if fpath not in referenced and os.path.isfile(fpath):
204
+ orphan_count += 1
205
+ name_base = os.path.splitext(f)[0]
206
+ if os.path.exists(index_path):
207
+ try:
208
+ with open(index_path, "r", errors="replace") as idx:
209
+ idx_content = idx.read()
210
+ if f"[[{name_base}]]" not in idx_content:
211
+ if not dry_run:
212
+ with open(index_path, "a") as idx:
213
+ idx.write(f"- [[{name_base}]] (orphan auto-linked)\n")
214
+ fixed_count += 1
215
+ print(f" 🔗 {rel} -> index.md")
216
+ except Exception as e:
217
+ print(f" WARN: {e}")
218
+
219
+ print(f" 发现 {orphan_count} 个孤儿页,已关联 {fixed_count} 个")
220
+ PYEOF
221
+
222
+ # ── Summary ────────────────────────────────────────────
223
+ TOTAL_FIXED=$((FIXED_LINKS + CREATED_FILES + PADDED_FILES))
224
+
225
+ echo ""
226
+ echo "📊 Auto-Fix Summary"
227
+ echo "-------------------"
228
+ echo " 空链接清理: $FIXED_LINKS"
229
+ echo " 缺失文件创建: $CREATED_FILES"
230
+ echo " 过小文件补充: $PADDED_FILES"
231
+ echo " 总计修复: $TOTAL_FIXED"
232
+
233
+ if $DRY_RUN; then
234
+ echo ""
235
+ echo "(DRY RUN — 未实际修改)"
236
+ fi
237
+
238
+ exit 0