#!/usr/bin/env python # -*- coding: utf-8 -*- """ populate-relations.py — Phase 3 P1: 批量填充 wiki 页面的 relations 字段 策略(自动推断,无 LLM 依赖): 1. **Source-grouping**:共享同一 raw source 的页面群,互为 part_of 关系 - 每组中被引用最多的页面成为 hub - 其余页面 part_of → hub 2. **Type-based**: - entity (人物) → example_of 对应 concept(如 毛泽东 example_of 教员的终极答案) - place → part_of 上级地点(如 西安 part_of 陕西省) 3. **Cross-link inference**:页面正文中的 [[wikilink]] 若目标存在, 且源页面 type=concept,推断 extends 关系(仅取 top 2 避免噪声) 安全措施: - 幂等:已有 relations 不重复添加 - 仅处理 wiki/*.md(排除 index/log/archive) - --dry-run 预览,--apply 执行 - 每页最多添加 5 条 relations(防爆裂) 用法: python tools/scripts/populate-relations.py --dry-run # 预览 python tools/scripts/populate-relations.py --apply # 执行 python tools/scripts/populate-relations.py --apply --max-per-page 3 """ import argparse import json import re import sys from collections import defaultdict from pathlib import Path try: import yaml except ImportError: print("ERROR: pyyaml required"); sys.exit(1) VAULT = Path(r"D:\Applications\app\kepano-obsidian-main") WIKI = VAULT / "wiki" EXCLUDE = {"index.md", "log.md"} MAX_RELATIONS_DEFAULT = 5 def read_frontmatter(fp: Path) -> tuple[dict, str]: content = fp.read_text(encoding="utf-8") m = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) if not m: return {}, content try: front = yaml.safe_load(m.group(1)) or {} except Exception: return {}, content return front, content def write_frontmatter(fp: Path, front: dict, body: str): # Strip any existing frontmatter from body to avoid duplication body_clean = re.sub(r"^---\n.*?\n---\n?", "", body, count=1, flags=re.DOTALL).lstrip() new_yaml = yaml.dump(front, allow_unicode=True, default_flow_style=False, sort_keys=False) fp.write_text(f"---\n{new_yaml}---\n\n{body_clean}", encoding="utf-8") def get_source_key(front: dict) -> str: """Extract a normalized source key for grouping""" source = front.get("source", "") if not source: return "" s = str(source).strip().strip('"').strip("'") # Remove [[ ]] and raw/ prefix s = re.sub(r"\[\[(?:raw/)?(.+?)(?:\|.*)?\]\]", r"\1", s) # Remove .md extension s = re.sub(r"\.md$", "", s) # Remove path prefixes s = s.split("/")[-1] return s def get_inbound_counts() -> dict[str, int]: """Count inbound wikilinks for each wiki page""" counts = defaultdict(int) all_content = "" for fp in VAULT.rglob("*.md"): if ".git" in str(fp): continue try: all_content += fp.read_text(encoding="utf-8") + "\n" except Exception: continue for m in re.finditer(r"\[\[([^\]|#]+?)(?:[#\]|]|$)", all_content): name = m.group(1).strip() if name and not name.startswith("http"): counts[name] += 1 return counts def extract_wikilinks(content: str) -> list[str]: """Extract wikilink targets from page body (not frontmatter)""" # Remove frontmatter body = re.sub(r"^---\n.*?\n---", "", content, count=1, flags=re.DOTALL) links = [] for m in re.finditer(r"\[\[([^\]|#]+?)(?:[#\]|])", body): target = m.group(1).strip() if target and not target.startswith("http") and not target.startswith("raw/"): links.append(target) return links def dedup_existing(front: dict) -> set[tuple[str, str]]: """Get existing relation (type, target) pairs""" existing = set() for r in front.get("relations", []) or []: if isinstance(r, dict): t = r.get("target", "").strip("[]") existing.add((r.get("type", ""), t)) return existing def main(): parser = argparse.ArgumentParser(description="Batch populate relations for wiki pages") parser.add_argument("--dry-run", action="store_true", help="Preview without writing") parser.add_argument("--apply", action="store_true", help="Write changes to files") parser.add_argument("--max-per-page", type=int, default=MAX_RELATIONS_DEFAULT) args = parser.parse_args() if not args.dry_run and not args.apply: args.dry_run = True # default to dry-run for safety # Phase 1: Read all pages pages = {} for fp in sorted(WIKI.glob("*.md")): if fp.name in EXCLUDE: continue front, content = read_frontmatter(fp) if not front: continue pages[fp.stem] = {"fp": fp, "front": front, "content": content} print(f"Loaded {len(pages)} wiki pages") # Phase 2: Compute inbound counts inbound = get_inbound_counts() print(f"Computed inbound counts for {len(inbound)} link targets") # Phase 3: Group by source source_groups = defaultdict(list) for name, info in pages.items(): src = get_source_key(info["front"]) if src: source_groups[src].append(name) # Phase 4: Infer relations additions = defaultdict(list) # page -> list of {type, target, ...} # 4a: Source-grouping → part_of hub multi_page_groups = {s: g for s, g in source_groups.items() if len(g) > 1} print(f"\nSource groups with >1 page: {len(multi_page_groups)}") for src, group in multi_page_groups.items(): # Hub = most referenced page in group hub = max(group, key=lambda p: inbound.get(p, 0)) for page in group: if page == hub: continue additions[page].append({ "type": "part_of", "target": f"[[{hub}]]", "description": f"同源:{src}", "confidence": 3, }) # 4b: Single-source pages → extends the source concept if source is a wiki page single_source = {s: g[0] for s, g in source_groups.items() if len(g) == 1} for src, page in single_source.items(): info = pages.get(page) if not info: continue page_type = info["front"].get("type", "") if isinstance(page_type, list): page_type = page_type[0] if page_type else "" page_type = str(page_type).lower() # If source matches a wiki page name, add extends if src in pages and src != page: src_type = pages[src]["front"].get("type", "") if isinstance(src_type, list): src_type = src_type[0] if src_type else "" src_type = str(src_type).lower() if page_type == "entity" and src_type in ("concept", "reference"): additions[page].append({ "type": "example_of", "target": f"[[{src}]]", "confidence": 3, }) elif page_type == "concept" and src_type == "concept": additions[page].append({ "type": "extends", "target": f"[[{src}]]", "confidence": 3, }) elif page_type in ("place",) and src_type in ("place", "concept"): additions[page].append({ "type": "part_of", "target": f"[[{src}]]", "confidence": 3, }) # 4c: Cross-link inference for concept pages (top 2 wikilinks) for name, info in pages.items(): page_type = info["front"].get("type", "") if isinstance(page_type, list): page_type = page_type[0] if page_type else "" if str(page_type).lower() not in ("concept", "method"): continue links = extract_wikilinks(info["content"]) seen = set() added = 0 for target in links: if added >= 2: break if target == name or target in seen: continue if target not in pages: continue seen.add(target) additions[name].append({ "type": "extends", "target": f"[[{target}]]", "confidence": 2, }) added += 1 # Phase 5: Apply (single pass — dry-run prints, apply writes) total_added = 0 pages_modified = 0 for name in sorted(pages.keys()): pending = additions.get(name, []) if not pending: continue info = pages[name] front = info["front"] existing = dedup_existing(front) new_rels = list(front.get("relations", []) or []) added = 0 for rel in pending: key = (rel["type"], rel["target"].strip("[]")) if key in existing: continue if len(new_rels) >= args.max_per_page: break new_rels.append(rel) existing.add(key) added += 1 if added == 0: continue front["relations"] = new_rels pages_modified += 1 total_added += added if args.dry_run: print(f" [[{name}]]: +{added}") for rel in new_rels[-added:]: print(f" --{rel['type']}--> {rel['target']}") mode = "DRY RUN" if args.dry_run else "APPLYING" print(f"\n=== {mode} ===") print(f" Pages to modify: {pages_modified}") print(f" Relations to add: {total_added}") if args.apply and pages_modified > 0: written = 0 for name in sorted(pages.keys()): info = pages[name] front = info["front"] rels = front.get("relations", []) if not rels: continue # Only write if relations were newly populated (not pre-existing) # Check by seeing if this page was in our additions map if name not in additions: continue write_frontmatter(info["fp"], front, info["content"]) written += 1 print(f" Written to {written} files") if __name__ == "__main__": main()