File size: 6,196 Bytes
216004d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
from __future__ import annotations
# llm-wiki-version: 2.0.0
# runtime: dev-only

"""
crystallize.py — v2 晶化 (crystallization) tool.

Rohit G00 v2 范式:从散落各页的"重复断言"中蒸馏出稳定的晶化条目。

工作流程:
  1. 扫描 docs/wiki/ 下所有页面,提取 `<!-- claim: <text> | sources: src_xxx, src_yyy -->` 注释
  2. 按 (normalized text) 聚合
  3. 出现 ≥min_occurrences 次的 claim 视为"晶化候选"
  4. 写入 manifests/crystallized.csv
  5. 在 docs/wiki/crystallized-claims.md 渲染晶化表

Usage:
  python3 scripts/crystallize.py --min-occurrences 2
  python3 scripts/crystallize.py --min-occurrences 1 --dry-run
"""

import argparse
import csv
import re
import sys
from collections import defaultdict
from datetime import date
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
WIKI_ROOT = ROOT / "docs" / "wiki"
MANIFEST_DIR = ROOT / "manifests"
CRYSTALLIZED_CSV = MANIFEST_DIR / "crystallized.csv"
CRYSTALLIZED_MD = WIKI_ROOT / "crystallized-claims.md"

CLAIM_RE = re.compile(r"<!--\s*claim:\s*(.+?)\s*\|\s*sources:\s*(.+?)\s*-->", re.IGNORECASE)


def normalize_claim(text: str) -> str:
    """Normalize claim text for grouping: lowercase, strip whitespace, collapse spaces."""
    return re.sub(r"\s+", " ", text.strip().lower())


def extract_claims_from_page(path: Path) -> list[tuple[str, list[str]]]:
    """Extract (claim_text, [source_ids]) tuples from a page's HTML comments."""
    text = path.read_text(encoding="utf-8")
    out: list[tuple[str, list[str]]] = []
    for match in CLAIM_RE.finditer(text):
        claim_text = match.group(1).strip()
        sources_raw = match.group(2).strip()
        sources = [s.strip() for s in re.split(r"[,;]\s*", sources_raw) if s.strip()]
        out.append((claim_text, sources))
    return out


def main() -> int:
    parser = argparse.ArgumentParser(description="v2 crystallization tool")
    parser.add_argument("--wiki-root", type=Path, default=WIKI_ROOT)
    parser.add_argument("--min-occurrences", type=int, default=2,
                        help="minimum times a claim must appear to be crystallized (default: 2)")
    parser.add_argument("--dry-run", action="store_true", help="don't write files, just report")
    parser.add_argument("--output-csv", type=Path, default=CRYSTALLIZED_CSV)
    parser.add_argument("--output-md", type=Path, default=CRYSTALLIZED_MD)
    args = parser.parse_args()

    if not args.wiki_root.exists():
        print(f"crystallize: {args.wiki_root} does not exist")
        return 1

    md_files = sorted(args.wiki_root.rglob("*.md"))
    # claim_text_normalized -> {claim_text_canonical, sources_set, pages}
    claim_index: dict[str, dict] = defaultdict(lambda: {
        "text": "",
        "sources": set(),
        "pages": set(),
    })

    for path in md_files:
        rel = path.relative_to(args.wiki_root).as_posix()
        for text, sources in extract_claims_from_page(path):
            key = normalize_claim(text)
            claim_index[key]["text"] = text  # use first occurrence as canonical
            claim_index[key]["sources"].update(sources)
            claim_index[key]["pages"].add(rel)

    # Filter by min_occurrences
    candidates = {
        k: v for k, v in claim_index.items() if len(v["pages"]) >= args.min_occurrences
    }

    # Stable sort: by occurrence count desc, then by text
    sorted_candidates = sorted(
        candidates.items(),
        key=lambda kv: (-len(kv[1]["pages"]), kv[1]["text"]),
    )

    if not sorted_candidates:
        print("crystallize: no claims found with --min-occurrences={}".format(args.min_occurrences))
        print("- hint: add <!-- claim: <text> | sources: src_xxx --> comments to wiki pages")
        return 0

    today = date.today().isoformat()

    if not args.dry_run:
        MANIFEST_DIR.mkdir(parents=True, exist_ok=True)
        with args.output_csv.open("w", encoding="utf-8", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["claim_id", "text", "sources", "first_seen", "last_validated", "occurrences", "pages"])
            for i, (key, info) in enumerate(sorted_candidates, start=1):
                claim_id = f"clm_{i:03d}"
                writer.writerow([
                    claim_id,
                    info["text"],
                    ";".join(sorted(info["sources"])),
                    today,
                    today,
                    len(info["pages"]),
                    ";".join(sorted(info["pages"])),
                ])

        # Render MD
        md_lines: list[str] = ["---", "title: 晶化断言", "source: crystallize.py",
                               f"created: {today}", f"updated: {today}", f"last_confirmed: {today}",
                               "audience: self", "stage: crystallized", "status: current",
                               "tags: [crystallized, claims, v2]", "---", "",
                               "# 晶化断言 (Crystallized Claims)", "",
                               f"生成时间: {today} | 最小出现次数: {args.min_occurrences} | 总数: {len(sorted_candidates)}",
                               "",
                               "| ID | 断言 | 出现次数 | 来源 | 出现页 |",
                               "|----|------|----------|------|--------|"]
        for i, (key, info) in enumerate(sorted_candidates, start=1):
            claim_id = f"clm_{i:03d}"
            md_lines.append(
                f"| {claim_id} | {info['text']} | {len(info['pages'])} | "
                f"{', '.join(sorted(info['sources']))} | {', '.join(sorted(info['pages']))} |"
            )
        args.output_md.write_text("\n".join(md_lines) + "\n", encoding="utf-8")

    # Report
    print("crystallize:")
    print(f"- scanned pages: {len(md_files)}")
    print(f"- unique claims: {len(claim_index)}")
    print(f"- candidates (occurrences >= {args.min_occurrences}): {len(sorted_candidates)}")
    if not args.dry_run:
        print(f"- wrote: {args.output_csv}")
        print(f"- wrote: {args.output_md}")
    else:
        print("- dry-run: no files written")
    return 0


if __name__ == "__main__":
    sys.exit(main())