759 lines
30 KiB
Python
759 lines
30 KiB
Python
|
|
"""Wiki 页面 frontmatter 的 Pydantic 校验模型。
|
|||
|
|
|
|||
|
|
用法:
|
|||
|
|
python schema.py .wiki/my-research/
|
|||
|
|
python schema.py .wiki/my-research/entities/alpha-corp.md
|
|||
|
|
|
|||
|
|
Agent 在 ingest 完成后应自动运行校验。lint 操作也会调用。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import sys
|
|||
|
|
from datetime import date
|
|||
|
|
from enum import Enum
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any, Optional, Union
|
|||
|
|
|
|||
|
|
import yaml
|
|||
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 枚举 ──────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
class PageType(str, Enum):
|
|||
|
|
source = "source"
|
|||
|
|
entity = "entity"
|
|||
|
|
concept = "concept"
|
|||
|
|
analysis = "analysis"
|
|||
|
|
mental_model = "mental-model"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Confidence(str, Enum):
|
|||
|
|
high = "high"
|
|||
|
|
medium = "medium"
|
|||
|
|
low = "low"
|
|||
|
|
contested = "contested"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SourceType(str, Enum):
|
|||
|
|
primary = "一手"
|
|||
|
|
authoritative_secondary = "二手·权威"
|
|||
|
|
secondary = "二手"
|
|||
|
|
hearsay = "转述"
|
|||
|
|
inference = "推断"
|
|||
|
|
oral = "口述"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 关系 ──────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
class Relation(BaseModel):
|
|||
|
|
"""页面间的类型化关系。"""
|
|||
|
|
target: str # 目标页面 slug
|
|||
|
|
type: str # 关系类型
|
|||
|
|
|
|||
|
|
@field_validator("type")
|
|||
|
|
@classmethod
|
|||
|
|
def known_type(cls, v: str) -> str:
|
|||
|
|
"""建议使用标准关系类型,但不强制。"""
|
|||
|
|
standard = {
|
|||
|
|
"part_of", "manages", "regulated_by", "competes_with",
|
|||
|
|
"implements", "derived_from", "contradicts", "influenced_by",
|
|||
|
|
"applies_to", "supplies", "subsidiary_of", "contrasted_with",
|
|||
|
|
}
|
|||
|
|
if v not in standard:
|
|||
|
|
import warnings
|
|||
|
|
warnings.warn(
|
|||
|
|
f"非标准关系类型 '{v}'(允许使用,但建议对齐标准类型:{', '.join(sorted(standard))})",
|
|||
|
|
stacklevel=2,
|
|||
|
|
)
|
|||
|
|
return v
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 三重验证(cognitive 类型专用)──────────────────────────
|
|||
|
|
|
|||
|
|
class MentalModelVerification(BaseModel):
|
|||
|
|
"""心智模型的三重验证结果。"""
|
|||
|
|
cross_domain: bool = False # 跨域复现
|
|||
|
|
generative: bool = False # 有生成力
|
|||
|
|
exclusive: bool = False # 有排他性
|
|||
|
|
domains: list[str] = Field(default_factory=list) # 出现过的领域
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 页面基类 ──────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
class BasePage(BaseModel):
|
|||
|
|
"""所有页面的公共字段。"""
|
|||
|
|
title: str
|
|||
|
|
type: PageType
|
|||
|
|
created: Union[str, date] # YYYY-MM-DD(YAML 可能解析为 date 对象)
|
|||
|
|
updated: Union[str, date] # YYYY-MM-DD
|
|||
|
|
sources: list[str] = Field(default_factory=list)
|
|||
|
|
confidence: Confidence = Confidence.high
|
|||
|
|
|
|||
|
|
@field_validator("created", "updated", mode="before")
|
|||
|
|
@classmethod
|
|||
|
|
def coerce_date(cls, v: Any) -> str:
|
|||
|
|
"""YAML 会把 2026-04-07 自动解析为 datetime.date,统一转为 str。"""
|
|||
|
|
if isinstance(v, date):
|
|||
|
|
return v.isoformat()
|
|||
|
|
if isinstance(v, str):
|
|||
|
|
try:
|
|||
|
|
date.fromisoformat(v)
|
|||
|
|
except ValueError:
|
|||
|
|
raise ValueError(f"日期必须为 YYYY-MM-DD 格式: {v}")
|
|||
|
|
return v
|
|||
|
|
raise ValueError(f"日期类型错误: {type(v)}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SourcePage(BasePage):
|
|||
|
|
"""source 类型页面。"""
|
|||
|
|
type: PageType = PageType.source
|
|||
|
|
source_type: SourceType
|
|||
|
|
source_origin: str # 来源出处
|
|||
|
|
source_date: Union[str, date] # 原始材料日期
|
|||
|
|
source_url: str = "" # 来源 URL
|
|||
|
|
|
|||
|
|
@field_validator("source_date", mode="before")
|
|||
|
|
@classmethod
|
|||
|
|
def coerce_source_date(cls, v: Any) -> str:
|
|||
|
|
if isinstance(v, date):
|
|||
|
|
return v.isoformat()
|
|||
|
|
return v
|
|||
|
|
|
|||
|
|
@model_validator(mode="after")
|
|||
|
|
def sources_should_be_empty(self) -> "SourcePage":
|
|||
|
|
if self.sources:
|
|||
|
|
raise ValueError("source 类型页面的 sources 应为空列表")
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DataPage(BasePage):
|
|||
|
|
"""entity / concept / analysis 页面。
|
|||
|
|
结构化数据(data/history)存在 data.db 中,不在 frontmatter。
|
|||
|
|
relations 保留在 frontmatter(Obsidian wikilink 渲染),同时写入 data.db。
|
|||
|
|
"""
|
|||
|
|
relations: list[Relation] = Field(default_factory=list)
|
|||
|
|
|
|||
|
|
@model_validator(mode="after")
|
|||
|
|
def sources_not_empty(self) -> "DataPage":
|
|||
|
|
if self.type != PageType.source and not self.sources:
|
|||
|
|
raise ValueError(f"{self.type.value} 类型页面的 sources 不能为空")
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MentalModelPage(BasePage):
|
|||
|
|
"""mental-model 类型页面(cognitive wiki 专用)。"""
|
|||
|
|
type: PageType = PageType.mental_model
|
|||
|
|
verification: Optional[MentalModelVerification] = None
|
|||
|
|
relations: list[Relation] = Field(default_factory=list)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 解析与校验 ────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def parse_frontmatter(path: Path) -> dict[str, Any]:
|
|||
|
|
"""从 markdown 文件中提取 YAML frontmatter。"""
|
|||
|
|
text = path.read_text(encoding="utf-8")
|
|||
|
|
if not text.startswith("---"):
|
|||
|
|
raise ValueError(f"页面缺少 YAML frontmatter: {path}")
|
|||
|
|
parts = text.split("---", 2)
|
|||
|
|
if len(parts) < 3:
|
|||
|
|
raise ValueError(f"YAML frontmatter 格式错误: {path}")
|
|||
|
|
return yaml.safe_load(parts[1]) or {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_page(path: Path) -> tuple[bool, str]:
|
|||
|
|
"""校验单个页面。返回 (通过?, 消息)。"""
|
|||
|
|
try:
|
|||
|
|
fm = parse_frontmatter(path)
|
|||
|
|
except Exception as e:
|
|||
|
|
return False, f"PARSE ERROR: {e}"
|
|||
|
|
|
|||
|
|
page_type = fm.get("type", "")
|
|||
|
|
warnings_list: list[str] = []
|
|||
|
|
|
|||
|
|
# data/history 应在 data.db 中,不在 frontmatter
|
|||
|
|
if "data" in fm and fm["data"]:
|
|||
|
|
warnings_list.append("MIGRATE: frontmatter 中有 data 字段,应迁移到 data.db(python store.py init)")
|
|||
|
|
if "history" in fm and fm["history"]:
|
|||
|
|
warnings_list.append("MIGRATE: frontmatter 中有 history 字段,应迁移到 data.db")
|
|||
|
|
|
|||
|
|
# 从 fm 中移除 data/history 避免 Pydantic 报错(模型已不包含这些字段)
|
|||
|
|
fm_clean = {k: v for k, v in fm.items() if k not in ("data", "history")}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
if page_type == "source":
|
|||
|
|
SourcePage(**fm_clean)
|
|||
|
|
elif page_type == "mental-model":
|
|||
|
|
MentalModelPage(**fm_clean)
|
|||
|
|
elif page_type in ("entity", "concept", "analysis"):
|
|||
|
|
DataPage(**fm_clean)
|
|||
|
|
else:
|
|||
|
|
return False, f"UNKNOWN TYPE: {page_type}"
|
|||
|
|
if warnings_list:
|
|||
|
|
return True, "OK (⚠️ " + "; ".join(warnings_list) + ")"
|
|||
|
|
return True, "OK"
|
|||
|
|
except Exception as e:
|
|||
|
|
return False, f"VALIDATION ERROR: {e}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_wiki(wiki_dir: Path) -> list[tuple[str, bool, str]]:
|
|||
|
|
"""校验整个 wiki 目录。返回 [(文件名, 通过?, 消息)]。"""
|
|||
|
|
results = []
|
|||
|
|
for subdir in ["sources", "entities", "concepts", "analyses", "mental-models"]:
|
|||
|
|
d = wiki_dir / subdir
|
|||
|
|
if not d.exists():
|
|||
|
|
continue
|
|||
|
|
for f in sorted(d.glob("*.md")):
|
|||
|
|
if f.name.startswith("_"):
|
|||
|
|
continue
|
|||
|
|
ok, msg = validate_page(f)
|
|||
|
|
rel = f.relative_to(wiki_dir)
|
|||
|
|
results.append((str(rel), ok, msg))
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Report 数据提取 ──────────────────────────────────────
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import re
|
|||
|
|
from collections import Counter
|
|||
|
|
|
|||
|
|
def _page_body(path: Path) -> str:
|
|||
|
|
"""读取 markdown 正文(frontmatter 之后的部分)。"""
|
|||
|
|
text = path.read_text(encoding="utf-8")
|
|||
|
|
parts = text.split("---", 2)
|
|||
|
|
return parts[2] if len(parts) >= 3 else ""
|
|||
|
|
|
|||
|
|
def _extract_wikilinks(body: str) -> list[str]:
|
|||
|
|
"""提取正文中的 [[slug]] 或 [[slug|display]] 链接。"""
|
|||
|
|
return re.findall(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]", body)
|
|||
|
|
|
|||
|
|
def collect_report_data(wiki_dir: Path) -> dict[str, Any]:
|
|||
|
|
"""扫描 wiki 目录,提取完整的报告数据。"""
|
|||
|
|
meta_path = wiki_dir / "meta.yaml"
|
|||
|
|
meta = {}
|
|||
|
|
if meta_path.exists():
|
|||
|
|
meta = yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}
|
|||
|
|
|
|||
|
|
nodes: list[dict] = []
|
|||
|
|
edges: list[dict] = []
|
|||
|
|
data_rows: list[dict] = []
|
|||
|
|
type_counts: Counter = Counter()
|
|||
|
|
confidence_counts: Counter = Counter()
|
|||
|
|
contested_pages: list[str] = []
|
|||
|
|
inlink_counts: Counter = Counter()
|
|||
|
|
page_slugs: set[str] = set()
|
|||
|
|
freshness: list[dict] = []
|
|||
|
|
|
|||
|
|
subdirs = ["sources", "entities", "concepts", "analyses", "mental-models"]
|
|||
|
|
for subdir in subdirs:
|
|||
|
|
d = wiki_dir / subdir
|
|||
|
|
if not d.exists():
|
|||
|
|
continue
|
|||
|
|
for f in sorted(d.glob("*.md")):
|
|||
|
|
if f.name.startswith("_"):
|
|||
|
|
continue
|
|||
|
|
slug = f.stem
|
|||
|
|
page_slugs.add(slug)
|
|||
|
|
rel_path = str(f.relative_to(wiki_dir))
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
fm = parse_frontmatter(f)
|
|||
|
|
except Exception:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
page_type = fm.get("type", "unknown")
|
|||
|
|
confidence = fm.get("confidence", "unknown")
|
|||
|
|
title = fm.get("title", slug)
|
|||
|
|
updated = str(fm.get("updated", ""))
|
|||
|
|
|
|||
|
|
type_counts[page_type] += 1
|
|||
|
|
confidence_counts[confidence] += 1
|
|||
|
|
|
|||
|
|
if confidence == "contested":
|
|||
|
|
contested_pages.append(rel_path)
|
|||
|
|
|
|||
|
|
# 节点颜色按类型
|
|||
|
|
color_map = {
|
|||
|
|
"entity": "#4A90D9",
|
|||
|
|
"concept": "#7B68EE",
|
|||
|
|
"source": "#50C878",
|
|||
|
|
"analysis": "#FF8C00",
|
|||
|
|
"mental-model": "#E91E63",
|
|||
|
|
}
|
|||
|
|
nodes.append({
|
|||
|
|
"id": slug,
|
|||
|
|
"label": title,
|
|||
|
|
"type": page_type,
|
|||
|
|
"color": color_map.get(page_type, "#999"),
|
|||
|
|
"confidence": confidence,
|
|||
|
|
"updated": updated,
|
|||
|
|
"path": rel_path,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
freshness.append({"slug": slug, "updated": updated, "type": page_type})
|
|||
|
|
|
|||
|
|
# 关系 → 边
|
|||
|
|
for rel in fm.get("relations", []):
|
|||
|
|
target = rel.get("target", "")
|
|||
|
|
rel_type = rel.get("type", "")
|
|||
|
|
if target:
|
|||
|
|
edges.append({
|
|||
|
|
"from": slug,
|
|||
|
|
"to": target,
|
|||
|
|
"label": rel_type,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# 正文 wikilinks → 入链统计
|
|||
|
|
body = _page_body(f)
|
|||
|
|
for linked_slug in _extract_wikilinks(body):
|
|||
|
|
inlink_counts[linked_slug] += 1
|
|||
|
|
|
|||
|
|
# 从 data.db 读取结构化数据(唯一数据源)
|
|||
|
|
db_path = wiki_dir / "data.db"
|
|||
|
|
if db_path.exists():
|
|||
|
|
import sqlite3 as _sqlite3
|
|||
|
|
_conn = _sqlite3.connect(str(db_path))
|
|||
|
|
_conn.row_factory = _sqlite3.Row
|
|||
|
|
for r in _conn.execute("SELECT * FROM data_points ORDER BY page_slug, field").fetchall():
|
|||
|
|
page_title = r["page_slug"]
|
|||
|
|
pg = _conn.execute("SELECT title FROM pages WHERE slug=?", (r["page_slug"],)).fetchone()
|
|||
|
|
if pg:
|
|||
|
|
page_title = pg["title"]
|
|||
|
|
data_rows.append({
|
|||
|
|
"page": page_title, "slug": r["page_slug"], "field": r["field"],
|
|||
|
|
"value": r["value"], "unit": r["unit"], "period": r["period"],
|
|||
|
|
"source": r["source_slug"], "verified": r["verified"],
|
|||
|
|
"confidence": r["confidence"] or "high",
|
|||
|
|
})
|
|||
|
|
# DB relations 补充 frontmatter edges
|
|||
|
|
edge_set = {(e["from"], e["to"], e["label"]) for e in edges}
|
|||
|
|
for r in _conn.execute("SELECT * FROM relations").fetchall():
|
|||
|
|
key = (r["from_slug"], r["to_slug"], r["type"])
|
|||
|
|
if key not in edge_set:
|
|||
|
|
edges.append({"from": r["from_slug"], "to": r["to_slug"], "label": r["type"]})
|
|||
|
|
# DB contested 补充
|
|||
|
|
contested_set = set(contested_pages)
|
|||
|
|
for r in _conn.execute("SELECT DISTINCT page_slug FROM data_points WHERE confidence='contested'").fetchall():
|
|||
|
|
for n in nodes:
|
|||
|
|
if n["id"] == r["page_slug"] and n["path"] not in contested_set:
|
|||
|
|
contested_pages.append(n["path"])
|
|||
|
|
contested_set.add(n["path"])
|
|||
|
|
_conn.close()
|
|||
|
|
|
|||
|
|
# frontmatter relations 也算入链
|
|||
|
|
for edge in edges:
|
|||
|
|
inlink_counts[edge["to"]] += 1
|
|||
|
|
|
|||
|
|
# 覆盖度分析
|
|||
|
|
coverage_gaps: list[dict] = []
|
|||
|
|
for slug in page_slugs:
|
|||
|
|
inlinks = inlink_counts.get(slug, 0)
|
|||
|
|
node = next((n for n in nodes if n["id"] == slug), None)
|
|||
|
|
if not node:
|
|||
|
|
continue
|
|||
|
|
if inlinks == 0 and node["type"] != "source":
|
|||
|
|
coverage_gaps.append({
|
|||
|
|
"slug": slug,
|
|||
|
|
"issue": "orphan",
|
|||
|
|
"detail": f"零入链(无其他页面引用)",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# 被大量引用但内容可能薄的页面(通过 wikilink 被引用但不在 page_slugs 中)
|
|||
|
|
for linked, count in inlink_counts.most_common():
|
|||
|
|
if linked not in page_slugs and count >= 2:
|
|||
|
|
coverage_gaps.append({
|
|||
|
|
"slug": linked,
|
|||
|
|
"issue": "missing",
|
|||
|
|
"detail": f"被引用 {count} 次但页面不存在",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"name": meta.get("name", wiki_dir.name),
|
|||
|
|
"ontology_type": meta.get("ontology_type", "unknown"),
|
|||
|
|
"description": meta.get("description", ""),
|
|||
|
|
"seed": meta.get("seed", ""),
|
|||
|
|
"total_pages": len(nodes),
|
|||
|
|
"type_counts": dict(type_counts),
|
|||
|
|
"confidence_counts": dict(confidence_counts),
|
|||
|
|
"contested_pages": contested_pages,
|
|||
|
|
"nodes": nodes,
|
|||
|
|
"edges": edges,
|
|||
|
|
"data_rows": data_rows,
|
|||
|
|
"freshness": freshness,
|
|||
|
|
"coverage_gaps": coverage_gaps,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
REPORT_HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|||
|
|
<title>Wiki Report: {{WIKI_NAME}}</title>
|
|||
|
|
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
|||
|
|
<style>
|
|||
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|||
|
|
body { font-family: -apple-system, "Noto Sans SC", sans-serif; background: #f0f2f5; color: #1a1a1a; height: 100vh; overflow: hidden; }
|
|||
|
|
|
|||
|
|
.layout { display: flex; height: 100vh; }
|
|||
|
|
|
|||
|
|
/* ── Left: header + cards + graph ── */
|
|||
|
|
.left { flex: 0 0 55%; display: flex; flex-direction: column; height: 100vh; border-right: 1px solid #e0e0e0; }
|
|||
|
|
.left-header { padding: 16px 20px 0; flex-shrink: 0; }
|
|||
|
|
h1 { font-size: 18px; font-weight: 600; color: #111; }
|
|||
|
|
.subtitle { color: #999; font-size: 12px; margin-top: 2px; }
|
|||
|
|
.cards { display: flex; gap: 8px; padding: 12px 20px 0; flex-shrink: 0; }
|
|||
|
|
.card { background: #fff; border-radius: 6px; padding: 8px 14px; border: 1px solid #e0e0e0; flex: 1; min-width: 0; }
|
|||
|
|
.card .num { font-size: 20px; font-weight: 700; color: #111; }
|
|||
|
|
.card .label { font-size: 10px; color: #aaa; margin-top: 1px; text-transform: uppercase; }
|
|||
|
|
.legend { display: flex; gap: 12px; flex-wrap: wrap; padding: 10px 20px 6px; flex-shrink: 0; }
|
|||
|
|
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #888; }
|
|||
|
|
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
|
|||
|
|
.graph-container { flex: 1; min-height: 0; margin: 0 12px 12px; background: #fff; border-radius: 8px; border: 1px solid #e0e0e0; }
|
|||
|
|
|
|||
|
|
/* ── Right: node detail + tables ── */
|
|||
|
|
.right { flex: 0 0 45%; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
|||
|
|
.right-scroll { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; gap: 12px; }
|
|||
|
|
.right-scroll::-webkit-scrollbar { width: 4px; }
|
|||
|
|
.right-scroll::-webkit-scrollbar-thumb { background: #d0d0d0; border-radius: 2px; }
|
|||
|
|
|
|||
|
|
/* ── Node detail ── */
|
|||
|
|
.node-detail { background: #fff; border-radius: 8px; border: 1px solid #e0e0e0; overflow: hidden; flex-shrink: 0; display: none; }
|
|||
|
|
.node-detail.active { display: block; }
|
|||
|
|
.nd-header { padding: 10px 14px; background: #fafafa; border-bottom: 1px solid #eee; display: flex; align-items: center; justify-content: space-between; }
|
|||
|
|
.nd-header h3 { font-size: 14px; font-weight: 600; }
|
|||
|
|
.nd-close { cursor: pointer; color: #aaa; font-size: 16px; padding: 0 4px; }
|
|||
|
|
.nd-close:hover { color: #333; }
|
|||
|
|
.nd-body { padding: 12px 14px; font-size: 12px; color: #555; line-height: 1.7; }
|
|||
|
|
.nd-body b { color: #333; }
|
|||
|
|
.nd-section { margin-top: 8px; }
|
|||
|
|
.nd-row { display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px solid #f5f5f5; }
|
|||
|
|
.nd-row:last-child { border-bottom: none; }
|
|||
|
|
.nd-key { color: #888; }
|
|||
|
|
.nd-val { font-weight: 600; color: #111; }
|
|||
|
|
.nd-rel { padding: 2px 0; }
|
|||
|
|
.nd-rel-arrow { color: #bbb; margin: 0 4px; }
|
|||
|
|
|
|||
|
|
/* ── Panel ── */
|
|||
|
|
.panel { background: #fff; border-radius: 8px; border: 1px solid #e0e0e0; overflow: hidden; flex-shrink: 0; }
|
|||
|
|
.panel-header { padding: 9px 14px; font-size: 11px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.5px; background: #fafafa; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
|
|||
|
|
.panel-count { background: #eee; color: #666; padding: 1px 6px; border-radius: 3px; font-size: 10px; }
|
|||
|
|
|
|||
|
|
/* ── Table ── */
|
|||
|
|
table { width: 100%; border-collapse: collapse; }
|
|||
|
|
th { text-align: left; padding: 7px 12px; font-size: 10px; color: #aaa; font-weight: 600; text-transform: uppercase; }
|
|||
|
|
td { padding: 6px 12px; font-size: 12px; border-top: 1px solid #f3f3f3; }
|
|||
|
|
.r { text-align: right; }
|
|||
|
|
tr:hover td { background: #f8f9fb; }
|
|||
|
|
|
|||
|
|
.badge { display: inline-block; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; }
|
|||
|
|
.badge-high { background: #dcfce7; color: #166534; }
|
|||
|
|
.badge-medium { background: #fef9c3; color: #854d0e; }
|
|||
|
|
.badge-low { background: #fee2e2; color: #991b1b; }
|
|||
|
|
.badge-contested { background: #f3e8ff; color: #7c3aed; }
|
|||
|
|
.badge-entity { background: #dbeafe; color: #1e40af; }
|
|||
|
|
.badge-concept { background: #ede9fe; color: #5b21b6; }
|
|||
|
|
.badge-source { background: #dcfce7; color: #166534; }
|
|||
|
|
.badge-analysis { background: #ffedd5; color: #9a3412; }
|
|||
|
|
.badge-mental-model { background: #fce7f3; color: #be185d; }
|
|||
|
|
.badge-orphan { background: #fee2e2; color: #991b1b; }
|
|||
|
|
.badge-missing { background: #fef9c3; color: #854d0e; }
|
|||
|
|
.empty { color: #ccc; font-style: italic; padding: 14px; text-align: center; font-size: 12px; }
|
|||
|
|
|
|||
|
|
/* ── Hint overlay on graph ── */
|
|||
|
|
.graph-hint { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); font-size: 11px; color: #bbb; pointer-events: none; transition: opacity 0.3s; }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="layout">
|
|||
|
|
|
|||
|
|
<!-- ── LEFT ── -->
|
|||
|
|
<div class="left">
|
|||
|
|
<div class="left-header">
|
|||
|
|
<h1>{{WIKI_NAME}}</h1>
|
|||
|
|
<div class="subtitle">{{WIKI_DESC}} · {{ONTOLOGY_TYPE}} · {{TOTAL_PAGES}} pages</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="cards" id="cards"></div>
|
|||
|
|
<div class="legend" id="legend"></div>
|
|||
|
|
<div class="graph-container" id="graph" style="position:relative;">
|
|||
|
|
<div class="graph-hint" id="graph-hint">click a node to inspect</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ── RIGHT ── -->
|
|||
|
|
<div class="right">
|
|||
|
|
<div class="right-scroll">
|
|||
|
|
<div class="node-detail" id="node-detail">
|
|||
|
|
<div class="nd-header">
|
|||
|
|
<h3 id="nd-title"></h3>
|
|||
|
|
<span class="nd-close" id="nd-close">×</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="nd-body" id="nd-body"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="panel">
|
|||
|
|
<div class="panel-header">Data Points <span class="panel-count" id="dp-count"></span></div>
|
|||
|
|
<div id="data-table"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="panel">
|
|||
|
|
<div class="panel-header">Pages <span class="panel-count" id="pg-count"></span></div>
|
|||
|
|
<div id="freshness-table"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="panel" id="contested-panel" style="display:none">
|
|||
|
|
<div class="panel-header">Contested <span class="panel-count" id="ct-count"></span></div>
|
|||
|
|
<div id="contested-list"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="panel" id="coverage-panel" style="display:none">
|
|||
|
|
<div class="panel-header">Coverage Gaps <span class="panel-count" id="cg-count"></span></div>
|
|||
|
|
<div id="coverage-table"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const DATA = {{JSON_DATA}};
|
|||
|
|
|
|||
|
|
// ── Cards ──
|
|||
|
|
const cardsEl = document.getElementById('cards');
|
|||
|
|
const cc = [
|
|||
|
|
{ num: DATA.total_pages, label: 'Pages' },
|
|||
|
|
...Object.entries(DATA.type_counts).map(([k,v]) => ({ num: v, label: k })),
|
|||
|
|
{ num: DATA.contested_pages.length, label: 'Contested' },
|
|||
|
|
{ num: DATA.edges.length, label: 'Relations' },
|
|||
|
|
];
|
|||
|
|
cardsEl.innerHTML = cc.map(c => `<div class="card"><div class="num">${c.num}</div><div class="label">${c.label}</div></div>`).join('');
|
|||
|
|
|
|||
|
|
// ── Legend ──
|
|||
|
|
const cm = { entity:'#4A90D9', concept:'#7B68EE', source:'#50C878', analysis:'#FF8C00', 'mental-model':'#E91E63' };
|
|||
|
|
document.getElementById('legend').innerHTML = Object.entries(cm).map(([t,c]) =>
|
|||
|
|
`<div class="legend-item"><div class="legend-dot" style="background:${c}"></div>${t}</div>`).join('');
|
|||
|
|
|
|||
|
|
// ── Graph ──
|
|||
|
|
const hint = document.getElementById('graph-hint');
|
|||
|
|
let network = null;
|
|||
|
|
if (DATA.nodes.length > 0) {
|
|||
|
|
// count connections per node for sizing
|
|||
|
|
const connCount = {};
|
|||
|
|
DATA.edges.forEach(e => { connCount[e.from] = (connCount[e.from]||0)+1; connCount[e.to] = (connCount[e.to]||0)+1; });
|
|||
|
|
const maxConn = Math.max(1, ...Object.values(connCount));
|
|||
|
|
|
|||
|
|
const graphNodes = DATA.nodes.filter(n => n.type !== 'source').map(n => {
|
|||
|
|
const c = connCount[n.id] || 0;
|
|||
|
|
const sz = 14 + (c / maxConn) * 22;
|
|||
|
|
return {
|
|||
|
|
id: n.id, label: n.label,
|
|||
|
|
color: { background: n.color, border: n.color, highlight: { background: '#fff', border: n.color } },
|
|||
|
|
font: { color: '#333', size: Math.max(12, sz * 0.65), face: '-apple-system, "Noto Sans SC", sans-serif' },
|
|||
|
|
shape: 'dot', size: sz,
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
const nodeIds = new Set(graphNodes.map(n => n.id));
|
|||
|
|
DATA.edges.forEach(e => {
|
|||
|
|
[e.to, e.from].forEach(id => {
|
|||
|
|
if (!nodeIds.has(id)) {
|
|||
|
|
graphNodes.push({ id, label: id, color:{ background:'#e0e0e0', border:'#ccc' }, font:{ color:'#999', size:11 }, shape:'dot', size:10 });
|
|||
|
|
nodeIds.add(id);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// dedupe edges: same from→to pair, merge labels
|
|||
|
|
const edgeMap = {};
|
|||
|
|
DATA.edges.forEach(e => {
|
|||
|
|
const key = e.from + '→' + e.to;
|
|||
|
|
if (edgeMap[key]) { edgeMap[key].label += '\n' + e.label; }
|
|||
|
|
else { edgeMap[key] = { ...e }; }
|
|||
|
|
});
|
|||
|
|
const graphEdges = Object.values(edgeMap).map((e,i) => ({
|
|||
|
|
id: i, from: e.from, to: e.to, label: e.label,
|
|||
|
|
color: { color:'#d0d0d0', highlight:'#888' },
|
|||
|
|
font: { color:'#aaa', size:10, strokeWidth:3, strokeColor:'#fff', multi:'md', face:'monospace' },
|
|||
|
|
arrows: { to:{ scaleFactor:0.5 } },
|
|||
|
|
smooth: { type:'curvedCW', roundness: 0.18 },
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
network = new vis.Network(document.getElementById('graph'), { nodes: graphNodes, edges: graphEdges }, {
|
|||
|
|
physics: {
|
|||
|
|
solver: 'forceAtlas2Based',
|
|||
|
|
forceAtlas2Based: { gravitationalConstant: -80, springLength: 200, springConstant: 0.04, damping: 0.6 },
|
|||
|
|
stabilization: { iterations: 150 },
|
|||
|
|
},
|
|||
|
|
interaction: { hover: true, tooltipDelay: 150, zoomView: true, navigationButtons: false },
|
|||
|
|
layout: { improvedLayout: true },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// fit after stabilize
|
|||
|
|
network.on('stabilized', () => { network.fit({ animation: { duration: 300 } }); });
|
|||
|
|
|
|||
|
|
// ── Click node → right panel detail ──
|
|||
|
|
const ndEl = document.getElementById('node-detail');
|
|||
|
|
const ndTitle = document.getElementById('nd-title');
|
|||
|
|
const ndBody = document.getElementById('nd-body');
|
|||
|
|
document.getElementById('nd-close').onclick = () => { ndEl.classList.remove('active'); network.unselectAll(); };
|
|||
|
|
|
|||
|
|
network.on('click', function(p) {
|
|||
|
|
if (!p.nodes.length) { ndEl.classList.remove('active'); return; }
|
|||
|
|
hint.style.opacity = '0';
|
|||
|
|
const nid = p.nodes[0];
|
|||
|
|
const node = DATA.nodes.find(n => n.id === nid);
|
|||
|
|
if (!node) return;
|
|||
|
|
ndTitle.textContent = node.label;
|
|||
|
|
|
|||
|
|
let html = `<div><span class="badge badge-${node.type}">${node.type}</span> <span class="badge badge-${node.confidence}">${node.confidence}</span> ${node.updated}</div>`;
|
|||
|
|
|
|||
|
|
// data points
|
|||
|
|
const dp = DATA.data_rows.filter(r => r.slug === nid);
|
|||
|
|
if (dp.length) {
|
|||
|
|
html += '<div class="nd-section"><b>Data</b></div>';
|
|||
|
|
dp.forEach(d => {
|
|||
|
|
html += `<div class="nd-row"><span class="nd-key">${d.field}</span><span class="nd-val">${d.value} ${d.unit} <span style="color:#aaa;font-weight:400">${d.period}</span></span></div>`;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// relations
|
|||
|
|
const rels = DATA.edges.filter(e => e.from === nid || e.to === nid);
|
|||
|
|
if (rels.length) {
|
|||
|
|
html += '<div class="nd-section"><b>Relations</b></div>';
|
|||
|
|
rels.forEach(r => {
|
|||
|
|
if (r.from === nid)
|
|||
|
|
html += `<div class="nd-rel">${node.label} <span class="nd-rel-arrow">→</span> <span style="color:#7c3aed">${r.label}</span> <span class="nd-rel-arrow">→</span> ${r.to}</div>`;
|
|||
|
|
else
|
|||
|
|
html += `<div class="nd-rel">${r.from} <span class="nd-rel-arrow">→</span> <span style="color:#7c3aed">${r.label}</span> <span class="nd-rel-arrow">→</span> ${node.label}</div>`;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ndBody.innerHTML = html;
|
|||
|
|
ndEl.classList.add('active');
|
|||
|
|
ndEl.scrollIntoView({ behavior:'smooth', block:'start' });
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
document.getElementById('graph').innerHTML = '<div class="empty">No nodes</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Data Table ──
|
|||
|
|
document.getElementById('dp-count').textContent = DATA.data_rows.length;
|
|||
|
|
const dtEl = document.getElementById('data-table');
|
|||
|
|
if (DATA.data_rows.length) {
|
|||
|
|
dtEl.innerHTML = `<table><thead><tr><th>Page</th><th>Field</th><th class="r">Value</th><th>Unit</th><th>Period</th><th>Conf.</th></tr></thead><tbody>`
|
|||
|
|
+ DATA.data_rows.map(r =>
|
|||
|
|
`<tr><td>${r.page}</td><td>${r.field}</td><td class="r"><b>${r.value}</b></td><td>${r.unit}</td><td>${r.period}</td><td><span class="badge badge-${r.confidence}">${r.confidence}</span></td></tr>`
|
|||
|
|
).join('') + '</tbody></table>';
|
|||
|
|
} else { dtEl.innerHTML = '<div class="empty">No data points</div>'; }
|
|||
|
|
|
|||
|
|
// ── Pages ──
|
|||
|
|
document.getElementById('pg-count').textContent = DATA.freshness.length;
|
|||
|
|
const ftEl = document.getElementById('freshness-table');
|
|||
|
|
if (DATA.freshness.length) {
|
|||
|
|
const s = [...DATA.freshness].sort((a,b) => b.updated.localeCompare(a.updated));
|
|||
|
|
ftEl.innerHTML = `<table><thead><tr><th>Page</th><th>Type</th><th class="r">Updated</th></tr></thead><tbody>`
|
|||
|
|
+ s.map(r => `<tr><td>${r.slug}</td><td><span class="badge badge-${r.type}">${r.type}</span></td><td class="r">${r.updated}</td></tr>`).join('')
|
|||
|
|
+ '</tbody></table>';
|
|||
|
|
} else { ftEl.innerHTML = '<div class="empty">No pages</div>'; }
|
|||
|
|
|
|||
|
|
// ── Contested ──
|
|||
|
|
if (DATA.contested_pages.length) {
|
|||
|
|
document.getElementById('contested-panel').style.display = '';
|
|||
|
|
document.getElementById('ct-count').textContent = DATA.contested_pages.length;
|
|||
|
|
document.getElementById('contested-list').innerHTML = `<table><tbody>`
|
|||
|
|
+ DATA.contested_pages.map(p => `<tr><td><span class="badge badge-contested">contested</span> ${p}</td></tr>`).join('') + '</tbody></table>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Coverage ──
|
|||
|
|
if (DATA.coverage_gaps.length) {
|
|||
|
|
document.getElementById('coverage-panel').style.display = '';
|
|||
|
|
document.getElementById('cg-count').textContent = DATA.coverage_gaps.length;
|
|||
|
|
document.getElementById('coverage-table').innerHTML = `<table><thead><tr><th>Page</th><th>Issue</th><th>Detail</th></tr></thead><tbody>`
|
|||
|
|
+ DATA.coverage_gaps.map(r => `<tr><td>${r.slug}</td><td><span class="badge badge-${r.issue}">${r.issue}</span></td><td>${r.detail}</td></tr>`).join('')
|
|||
|
|
+ '</tbody></table>';
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def generate_report(wiki_dir: Path) -> Path:
|
|||
|
|
"""生成 wiki 可视化报告 HTML。返回输出文件路径。"""
|
|||
|
|
data = collect_report_data(wiki_dir)
|
|||
|
|
html = REPORT_HTML_TEMPLATE
|
|||
|
|
html = html.replace("{{WIKI_NAME}}", data["name"])
|
|||
|
|
html = html.replace("{{WIKI_DESC}}", data.get("description", ""))
|
|||
|
|
html = html.replace("{{ONTOLOGY_TYPE}}", data.get("ontology_type", ""))
|
|||
|
|
html = html.replace("{{TOTAL_PAGES}}", str(data["total_pages"]))
|
|||
|
|
html = html.replace("{{JSON_DATA}}", json.dumps(data, ensure_ascii=False, default=str))
|
|||
|
|
|
|||
|
|
out_path = wiki_dir / "_report.html"
|
|||
|
|
out_path.write_text(html, encoding="utf-8")
|
|||
|
|
return out_path
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── CLI ───────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
if len(sys.argv) < 2:
|
|||
|
|
print("Usage:")
|
|||
|
|
print(" python schema.py <wiki_dir> — validate all pages")
|
|||
|
|
print(" python schema.py <page.md> — validate one page")
|
|||
|
|
print(" python schema.py --report <wiki_dir> — generate visual report")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
# --report 模式
|
|||
|
|
if sys.argv[1] == "--report":
|
|||
|
|
if len(sys.argv) < 3:
|
|||
|
|
print("Usage: python schema.py --report <wiki_dir>")
|
|||
|
|
sys.exit(1)
|
|||
|
|
target = Path(sys.argv[2])
|
|||
|
|
if not target.is_dir():
|
|||
|
|
print(f"Not a directory: {target}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
out = generate_report(target)
|
|||
|
|
print(f"Report generated: {out}")
|
|||
|
|
sys.exit(0)
|
|||
|
|
|
|||
|
|
target = Path(sys.argv[1])
|
|||
|
|
|
|||
|
|
if target.is_file():
|
|||
|
|
ok, msg = validate_page(target)
|
|||
|
|
status = "✅" if ok else "❌"
|
|||
|
|
print(f"{status} {target.name}: {msg}")
|
|||
|
|
sys.exit(0 if ok else 1)
|
|||
|
|
|
|||
|
|
if target.is_dir():
|
|||
|
|
results = validate_wiki(target)
|
|||
|
|
if not results:
|
|||
|
|
print("No pages found.")
|
|||
|
|
sys.exit(0)
|
|||
|
|
|
|||
|
|
passed = sum(1 for _, ok, _ in results if ok)
|
|||
|
|
failed = sum(1 for _, ok, _ in results if not ok)
|
|||
|
|
|
|||
|
|
print(f"\n{'='*60}")
|
|||
|
|
print(f"Wiki Validation: {target.name}")
|
|||
|
|
print(f"{'='*60}\n")
|
|||
|
|
|
|||
|
|
for name, ok, msg in results:
|
|||
|
|
status = "✅" if ok else "❌"
|
|||
|
|
print(f" {status} {name}")
|
|||
|
|
if not ok:
|
|||
|
|
# 缩进显示错误详情
|
|||
|
|
for line in str(msg).split("\n"):
|
|||
|
|
print(f" {line}")
|
|||
|
|
|
|||
|
|
print(f"\n{'─'*60}")
|
|||
|
|
print(f" Total: {len(results)} | Passed: {passed} | Failed: {failed}")
|
|||
|
|
if failed == 0:
|
|||
|
|
print(" Result: ALL PASSED ✅")
|
|||
|
|
else:
|
|||
|
|
print(f" Result: {failed} FAILED ❌")
|
|||
|
|
print(f"{'─'*60}\n")
|
|||
|
|
|
|||
|
|
sys.exit(0 if failed == 0 else 1)
|
|||
|
|
|
|||
|
|
print(f"Not a file or directory: {target}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|