#!/usr/bin/env python # -*- coding: utf-8 -*- """ review-pages.py — Phase 3 wiki 页面评审器 两种模式: 1. LLM 评审(需 Ollama 后端):调用本地模型审核内容准确性,检测声明与 raw 来源 不匹配、跨页面矛盾、过时信息。 2. 静态评审(LLM 不可用时自动降级):基于 frontmatter 规则与 raw 来源比对, 检测低置信度、缺 source、断链、过时等结构问题。无外部依赖。 B-3.3 低置信度自动建议:confidence <= 2 的页面生成"需要更多证据"提示。 B-3.1 LLM 评审:可检测语义偏差(被 AI 过度概括/扭曲的结论)。 用法: python tools/scripts/review-pages.py --stale # 审查过时页面 python tools/scripts/review-pages.py --random 5 # 随机抽 5 页 python tools/scripts/review-pages.py --page "LLM-Wiki-v2" # 指定单页 python tools/scripts/review-pages.py --confidence-low # 低置信度页面 python tools/scripts/review-pages.py --random 3 --llm # 强制启用 LLM 评审 python tools/scripts/review-pages.py --json # JSON 输出 """ import argparse import json import random import re import shutil import subprocess import sys from datetime import date from pathlib import Path try: import yaml except ImportError: yaml = None VAULT = Path(r"D:\Applications\app\kepano-obsidian-main") WIKI = VAULT / "wiki" RAW = VAULT / "raw" TODAY = date.today() LLM_MODEL = "qwen2.5:7b" REVIEW_PROMPT = """请审核以下 Obsidian wiki 页面,输出 JSON(仅 JSON,无多余文字): { "accuracy_score": 1到5的整数, "issues": ["具体问题描述"], "confidence_match": true或false, "suggested_updates": ["可执行的改进建议"] } 重点关注:数字/百分比是否标注来源行号、结论是否被过度概括、frontmatter 是否完整。 页面内容(截断至 4000 字符): {content} """ 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 or not yaml: return {}, content try: return yaml.safe_load(m.group(1)) or {}, content except Exception: return {}, content def ollama_available() -> bool: return shutil.which("ollama") is not None def llm_review(page_name: str, content: str) -> dict | None: """调用 Ollama 审核单页,返回结构化结果""" if not ollama_available(): return None prompt = REVIEW_PROMPT.format(content=content[:4000]) try: result = subprocess.run( ["ollama", "run", LLM_MODEL, prompt], capture_output=True, text=True, timeout=180, encoding="utf-8", ) out = result.stdout.strip() start, end = out.find("{"), out.rfind("}") if start != -1 and end != -1: return json.loads(out[start:end + 1]) except Exception: pass return None def static_review(fp: Path) -> dict: """静态规则评审:无 LLM 依赖的结构化检查""" front, content = read_frontmatter(fp) issues = [] suggestions = [] cats = front.get("categories", []) or [] if not any("LLM Wiki" in str(c) for c in cats): issues.append("categories 缺少 [[LLM Wiki]]") tags = front.get("tags", []) or [] if "wiki" not in tags: issues.append("tags 缺少 wiki") if not front.get("type"): issues.append("缺少 type 字段") source = front.get("source", "") if not source or str(source).strip('[]"') == "": issues.append("缺少 source(wiki 层必填)") suggestions.append(f"补充 source 指向 raw/ 来源,参考同类页面:{fp.stem}") conf = front.get("confidence") if conf is not None and isinstance(conf, (int, float)) and conf <= 2: issues.append(f"低置信度 (confidence={conf})") suggestions.append("需要更多证据:补充多源交叉验证或将 confidence 提升至 3+") last = str(front.get("last_reviewed", "")) if last: try: overdue = (TODAY - date.fromisoformat(last)).days interval = front.get("review_interval_days", 180) if overdue > interval: issues.append(f"过时(上次审查 {last},超期 {overdue - interval} 天)") suggestions.append(f"复查内容并更新 last_reviewed 为 {TODAY.isoformat()}") except ValueError: issues.append(f"last_reviewed 格式无效:{last}") no_line = re.findall(r"\[raw:[^:\]\[]+\]", content) if no_line: issues.append(f"{len(no_line)} 条 raw 引用缺少行号") suggestions.append("运行 python tools/scripts/fix-raw-citations.py 自动补全行号") score = 5 - min(4, len([i for i in issues if i.startswith(("低置信", "过时", "缺少 source"))])) return { "page": fp.stem, "mode": "static", "accuracy_score": max(1, score), "issues": issues, "confidence_match": (conf is None or conf >= 3), "suggested_updates": suggestions, } def select_pages(args) -> list[Path]: all_pages = [p for p in sorted(WIKI.glob("*.md")) if p.name not in ("index.md", "log.md")] if args.page: fp = WIKI / f"{args.page}.md" return [fp] if fp.exists() else [] if args.confidence_low: return [p for p in all_pages if (lambda c: c is not None and isinstance(c, (int, float)) and c <= 2)( read_frontmatter(p)[0].get("confidence"))] if args.stale: selected = [] for p in all_pages: front, _ = read_frontmatter(p) last = str(front.get("last_reviewed", "")) interval = front.get("review_interval_days", 180) if not last: selected.append(p) continue try: if (TODAY - date.fromisoformat(last)).days > interval: selected.append(p) except ValueError: selected.append(p) return selected if args.random: return random.sample(all_pages, min(args.random, len(all_pages))) return all_pages def main(): parser = argparse.ArgumentParser(description="Review wiki pages (LLM or static fallback)") sel = parser.add_mutually_exclusive_group() sel.add_argument("--stale", action="store_true", help="Review stale pages only") sel.add_argument("--random", type=int, metavar="N", help="Randomly sample N pages") sel.add_argument("--page", help="Review a single page") sel.add_argument("--confidence-low", action="store_true", help="Review confidence<=2 pages") parser.add_argument("--llm", action="store_true", help="Force LLM review (requires Ollama)") parser.add_argument("--json", action="store_true", help="Output JSON") args = parser.parse_args() pages = select_pages(args) if not pages: print("No pages matched selection criteria.") return use_llm = args.llm or (not args.confidence_low) llm_on = use_llm and ollama_available() if use_llm and not llm_on: print("(Ollama 不可用,降级为静态评审模式。安装 Ollama 以启用 LLM 语义审核。)\n") reports = [] for fp in pages: front, content = read_frontmatter(fp) if llm_on: llm = llm_review(fp.stem, content) report = llm if llm else static_review(fp) if report: report["mode"] = "llm" else: report = static_review(fp) report.setdefault("page", fp.stem) reports.append(report) if args.json: print(json.dumps(reports, ensure_ascii=False, indent=2)) return print(f"=== Review Report ({'LLM' if llm_on else 'static'} mode, {len(reports)} pages) ===\n") needs_action = [r for r in reports if r["issues"]] for r in reports: flag = "OK " if not r["issues"] else "FIX" print(f"[{flag}] [[{r['page']}]] — score {r['accuracy_score']}/5") for issue in r["issues"]: print(f" • {issue}") for sug in r["suggested_updates"]: print(f" → {sug}") print(f"\nSummary: {len(needs_action)}/{len(reports)} pages need attention") if __name__ == "__main__": main()