survival_rate.py

scripts/survival_rate.py(132 行)

survival_rate.py
# -*- coding: utf-8 -*-
"""初稿生存率の計測(diff-learning スキル用)

使い方:
    python -X utf8 scripts/survival_rate.py <初稿パス> <最終版パス> [--json] [--th-keep 0.8] [--th-edit 0.5]

初稿を文単位(。!?改行区切り・10字未満除外)に割り、最終版の文と difflib で照合して
文字数加重の 生存(>=th-keep)/改変(th-edit..th-keep)/脱落(<th-edit) を出す。
"""
import argparse
import difflib
import io
import json
import re
import sys
from pathlib import Path

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")

MIN_SENT_LEN = 10


def load_text(path: str) -> str:
    t = Path(path).read_text(encoding="utf-8", errors="replace")
    # frontmatter
    if t.startswith("---"):
        m = re.match(r"^---\r?\n.*?\r?\n---\r?\n", t, re.S)
        if m:
            t = t[m.end():]
    # HTMLタグ・テンプレタグ・囲み記法の器を落とす(本文テキストだけ比較する)
    t = re.sub(r"<[^>]+>", " ", t)
    t = re.sub(r"\{\{[^}]*\}\}", " ", t)
    t = re.sub(r"^:{2,3}\S*\s*$", " ", t, flags=re.M)
    t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t)  # リンクは文言だけ残す
    t = re.sub(r"^#{1,6}\s*", "", t, flags=re.M)
    t = t.replace("==", " ").replace("**", "")
    t = re.sub(r"^\s*\|.*\|\s*$", " ", t, flags=re.M)  # テーブル行は文比較から除外
    t = re.sub(r"^:{2,3}\w+\s*", "", t, flags=re.M)     # 行頭の囲み記法ラベル
    return t


def sentences(text: str):
    parts = re.split(r"(?<=[。!?!?])|\r?\n", text)
    out = []
    for p in parts:
        s = re.sub(r"\s+", " ", (p or "")).strip(" ・-—–―`>*")
        if len(s) >= MIN_SENT_LEN:
            out.append(s)
    return out


def best_match(sent: str, finals, cutoff: float):
    """finals の中で最も似た文と類似度を返す(quick_ratio で足切りして高速化)"""
    sm = difflib.SequenceMatcher()
    sm.set_seq2(sent)
    best, best_r = None, 0.0
    for f in finals:
        sm.set_seq1(f)
        if sm.real_quick_ratio() <= best_r or sm.quick_ratio() <= best_r:
            continue
        r = sm.ratio()
        if r > best_r:
            best, best_r = f, r
            if best_r > 0.99:
                break
    return best, best_r


def main():
    ap = argparse.ArgumentParser(description="初稿と最終版を文単位で照合し、初稿生存率を出す")
    ap.add_argument("initial", help="初稿ファイル")
    ap.add_argument("final", help="最終版ファイル")
    ap.add_argument("--json", action="store_true", help="JSONで出力する")
    ap.add_argument("--th-keep", type=float, default=0.8, help="生存とみなす類似度の下限")
    ap.add_argument("--th-edit", type=float, default=0.5, help="改変とみなす類似度の下限(未満は脱落)")
    ap.add_argument("--max-examples", type=int, default=25, help="実例の最大表示件数")
    a = ap.parse_args()

    init_sents = sentences(load_text(a.initial))
    fin_sents = sentences(load_text(a.final))
    if not init_sents or not fin_sents:
        print("エラー: 文が抽出できませんでした(ファイル・形式を確認)", file=sys.stderr)
        sys.exit(1)

    kept, edited, dropped = [], [], []
    for s in init_sents:
        m, r = best_match(s, fin_sents, a.th_edit)
        if r >= a.th_keep:
            kept.append((s, m, r))
        elif r >= a.th_edit:
            edited.append((s, m, r))
        else:
            dropped.append((s, m, r))

    total = sum(len(s) for s in init_sents) or 1
    w = lambda rows: round(100.0 * sum(len(s) for s, _, _ in rows) / total, 1)
    result = {
        "initial": a.initial,
        "final": a.final,
        "sentences_initial": len(init_sents),
        "sentences_final": len(fin_sents),
        "survival_pct": w(kept),
        "edited_pct": w(edited),
        "dropped_pct": w(dropped),
        "counts": {"kept": len(kept), "edited": len(edited), "dropped": len(dropped)},
    }

    if a.json:
        result["edited_pairs"] = [
            {"initial": s, "final": m, "ratio": round(r, 2)}
            for s, m, r in sorted(edited, key=lambda x: -len(x[0]))[: a.max_examples]
        ]
        result["dropped"] = [s for s, _, _ in sorted(dropped, key=lambda x: -len(x[0]))[: a.max_examples]]
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return

    print("# 初稿生存率レポート")
    print(f"- 初稿: {a.initial}({len(init_sents)}文) / 最終版: {a.final}({len(fin_sents)}文)")
    print(f"- **生存 {result['survival_pct']}% / 改変 {result['edited_pct']}% / 脱落 {result['dropped_pct']}%**(文字数加重)")
    print()
    print(f"## 改変ペア(初稿 → 最終・上位{min(len(edited), a.max_examples)}件)")
    for s, m, r in sorted(edited, key=lambda x: -len(x[0]))[: a.max_examples]:
        print(f"- ({r:.2f}) ❌ {s}")
        print(f"        ⭕ {m}")
    print()
    print(f"## 脱落文(上位{min(len(dropped), a.max_examples)}件)")
    for s, _, _ in sorted(dropped, key=lambda x: -len(x[0]))[: a.max_examples]:
        print(f"- {s}")


if __name__ == "__main__":
    main()

MIT ライセンスで配布しています。