feat(vault): Phase 3 tools + batch citation/relation enrichment
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user