#!/usr/bin/env python3
"""
記事の「AIっぽさ」を機械的に測る。

きっかけ: 「**強調**の多用や定型表現が来た時点で読むのをやめる」という指摘（2026-08-04）。
AIに書かせた文章には見分けがつく癖がある。感想ではなく数字で出す。

使い方:
  python3 check_ai_ness.py src/content/posts/*.md
  python3 check_ai_ness.py 記事.md --verbose   # 指摘箇所も表示

判定するもの:
  1. **強調** の密度        … 多いほどAIっぽい
  2. 見出しの密度            … 細かく区切りすぎ
  3. 箇条書き・表の比率      … 文章ではなく資料になっている
  4. 定型フレーズ            … AIが好んで使う言い回し
  5. 文の長さの均一さ        … 人間はもっとばらつく
  6. 3点セット癖             … 何でも3つ並べたがる

しきい値は「人間が書いた文章の感覚」で決めた暫定値。実際の記事で調整していく。
"""

import argparse
import re
import statistics
import sys
from pathlib import Path

# AIが好んで使う言い回し。見つけたら指摘する。
# 「絶対に使うな」ではなく「無意識に多用していないか」を見るためのもの。
CLICHE = [
    "重要なのは", "ポイントは", "と言えるでしょう", "ではないでしょうか",
    "しっかりと", "きちんと", "非常に", "極めて", "まさに",
    "〜する上で", "という点で", "において重要", "を活用",
    "効率的に", "最適な", "最大限", "スムーズに", "シームレス",
    "深掘り", "俯瞰", "本質的に", "総合的に", "多角的に",
    "いかがでしたか", "参考になれば幸いです", "ぜひ",
    "革命的", "画期的", "圧倒的", "驚くべき",
]

# 見出しでよく使われる、中身の無い構造語
EMPTY_HEADING = ["まとめ", "はじめに", "おわりに", "ポイント", "メリット", "デメリット", "注意点"]

# 順序を示す接続語。段落の頭に置いて手順を並べるのは、AIが最も得意な形。
# 1つ2つは普通の日本語だが、同じ記事に3種類そろうと「列挙の足場」になる。
# 外部のAI判定ツール（User Local / webサクッとツールズ）で最も高い減点が付いたのがここだった。
# 私の判定は密度しか見ておらず、この癖を素通りさせていた（2026-08-04 追加）。
ORDER_WORDS = ["まず", "次に", "最後に", "加えて", "さらに", "それから", "そして"]
LIMIT_ORDER_KINDS = 2   # 段落頭に出てよい種類数。3種類そろったら指摘する

# 自分の仕事を無効にしてしまう言い方。
# 根拠を丁寧に説明した記事の最後に「しきい値は適当に置いた」と書いていたのを指摘された。
# 日本語としては正しいが、前半の説明を自分で打ち消してしまう。謙遜のつもりで書くので気づきにくい。
# これは文体ではなく論理の一貫性の問題なので、機械では「出た」ことしか分からない。
# 見つけたら、本文で根拠を説明していないか自分で確かめる（2026-08-04 追加）。
SELF_NEGATION = ["適当に置い", "適当に決め", "適当な数字", "テキトー",
                 "根拠はない", "深く考えていない", "なんとなく決め"]

# ここから下は「AIっぽさ」ではなく「読んで面白いか」を測る試み（2026-08-04 追加）。
# きっかけは「全部の記事が頭に入らない」という指摘。
# 文体の指摘は0件なのに面白くない、という状態が実際に起きた。
#
# 本人の書いた文章6本と、私が書いた記事16本を同じ物差しにかけたら、2つ差が出た。
#   文末の最頻型   私 72%  /  本人 29%
#   感情語の密度   私 0.4  /  本人 7.1（1,000字あたり）
# 目安は本人の値から取り、あいだを取って置いた。

# 文末の型。上から順に判定し、最初に当たったものだけ数える（重複させない）
ENDING_TYPES = [
    ("です・ます", r"(です|ます|ました|でした|ません)$"),
    ("問いかけ",   r"(か|かな|だろう|でしょう|かね|の)$"),
    ("ぼかし",     r"(みたいな|かも|気がする|と思う|らしい|ような|けど|から|よね|んです)$"),
    ("だ・である", r"(だ|である)$"),
    ("た・断定",   r"(た|ない|る)$"),
    ("体言止め",   r"[ぁ-んァ-ヶ一-龥ーA-Za-z0-9]$"),
]
LIMIT_ENDING_TOP = 0.50   # 最頻型がこれを超えたら単調

# 感情・主観の語。読み手が「人が書いた」と感じる手がかり。
# 事実だけで書くとゼロになる。ゼロの記事は正確だが読まれない。
EMOTION = ["嬉し", "悲し", "腹が立", "むかつ", "こわ", "怖", "つらい", "しんど", "疲れ",
           "焦", "困っ", "嫌", "好き", "楽し", "もういい", "やばい", "最悪", "うれし",
           "ムカ", "泣", "笑", "ほしい", "わからん", "知らん", "だるい", "うんざり",
           "長かった", "思ってい", "気づい", "驚", "안心", "안", "参った", "助かっ",
           "面倒", "しまった", "まずい", "ほっと", "情けな", "悔し"]
MIN_EMOTION = 3.0         # 1,000字あたり。これ未満は事実だけの文章

# しきい値（1,000字あたり）。超えたら指摘する。
# 強調だけは密度ではなく「1箇所でも使ったら指摘」にしている（2026-08-04 方針変更）。
# 目安を4.0にしていたが、2箇所でも目がそこへ行くので、このブログでは使わないことにした。
# 強調したいときは書式ではなく、文の順番か長さで解決する。
ALLOW_BOLD = False      # True に戻すと LIMIT_BOLD の密度判定に戻る
LIMIT_BOLD = 4.0        # **強調** の数（ALLOW_BOLD = True のときだけ使う）
LIMIT_HEADING = 3.0     # 見出しの数
LIMIT_LIST_RATIO = 0.35 # 箇条書き＋表が本文行に占める割合
LIMIT_CLICHE = 2.0      # 定型フレーズの数
MIN_SENT_STDEV = 12.0   # 文長の標準偏差。これ未満だと均一すぎる


def strip_frontmatter(text):
    if text.startswith("---"):
        parts = text.split("---", 2)
        if len(parts) == 3:
            return parts[2]
    return text


def analyze(path, verbose=False):
    raw = Path(path).read_text(encoding="utf-8")
    body = strip_frontmatter(raw)

    lines = body.split("\n")
    # コードブロックは書き手の癖ではないので除外する
    in_code, kept = False, []
    for ln in lines:
        if ln.strip().startswith("```"):
            in_code = not in_code
            continue
        if not in_code:
            kept.append(ln)
    lines = kept
    text = "\n".join(lines)

    chars = len(re.sub(r"\s", "", text))
    if chars == 0:
        return None
    per1k = 1000 / chars

    bold = re.findall(r"\*\*(.+?)\*\*", text)
    headings = [l for l in lines if re.match(r"^#{2,4} ", l)]
    list_lines = [l for l in lines if re.match(r"^\s*([-*+]|\d+\.)\s", l)]
    table_lines = [l for l in lines if l.strip().startswith("|")]
    body_lines = [l for l in lines if l.strip()]

    found_cliche = [c for c in CLICHE if c in text]
    empty_head = [h for h in headings if any(e in h for e in EMPTY_HEADING)]

    # 段落の頭に置かれた順序の接続語だけを見る（文中の「まず」は普通の日本語なので数えない）
    found_order = sorted({w for l in lines for w in ORDER_WORDS
                          if l.startswith(w) or l.startswith(w + "、")})

    # 文の長さ（句点で区切る）。箇条書き・表・見出しは除く
    prose = "\n".join(l for l in lines
                      if l.strip() and not re.match(r"^\s*([-*+#>|]|\d+\.)", l))
    sents = [s for s in re.split(r"[。！？]", prose) if len(s.strip()) >= 5]
    lens = [len(re.sub(r"\s", "", s)) for s in sents]
    stdev = statistics.stdev(lens) if len(lens) > 1 else 0.0
    mean = statistics.mean(lens) if lens else 0.0

    m = {
        "chars": chars,
        "bold": len(bold) * per1k,
        "heading": len(headings) * per1k,
        "list_ratio": (len(list_lines) + len(table_lines)) / max(1, len(body_lines)),
        "cliche": len(found_cliche) * per1k,
        "stdev": stdev,
        "mean": mean,
    }

    issues = []
    if not ALLOW_BOLD:
        if bold:
            issues.append(f"強調を{len(bold)}箇所使っている。このブログでは0にする方針"
                          "…強調は書式ではなく、文の順番か長さで作る")
    elif m["bold"] > LIMIT_BOLD:
        issues.append(f"**強調** が多い（1,000字あたり {m['bold']:.1f} 箇所 / 目安 {LIMIT_BOLD}）"
                      f"…全{len(bold)}箇所")
    if m["heading"] > LIMIT_HEADING:
        issues.append(f"見出しが細かすぎる（1,000字あたり {m['heading']:.1f} 個 / 目安 {LIMIT_HEADING}）")
    if m["list_ratio"] > LIMIT_LIST_RATIO:
        issues.append(f"箇条書き・表が多い（本文行の {m['list_ratio']*100:.0f}% / 目安 {LIMIT_LIST_RATIO*100:.0f}%）"
                      "…文章ではなく資料になっている")
    if m["cliche"] > LIMIT_CLICHE:
        issues.append(f"定型フレーズが多い（{len(found_cliche)}種類）: {' / '.join(found_cliche[:6])}")
    elif found_cliche:
        issues.append(f"定型フレーズ: {' / '.join(found_cliche)}")
    if 0 < stdev < MIN_SENT_STDEV:
        issues.append(f"文の長さが均一すぎる（標準偏差 {stdev:.1f} / 目安 {MIN_SENT_STDEV}以上）"
                      f"…平均{mean:.0f}字。人間はもっとばらつく")
    if empty_head:
        issues.append(f"中身の薄い見出し: {' / '.join(h.lstrip('# ') for h in empty_head)}")
    # 文末の型の偏り
    end_counts = {k: 0 for k, _ in ENDING_TYPES}
    for s in sents:
        for k, pat in ENDING_TYPES:
            if re.search(pat, s.strip()):
                end_counts[k] += 1
                break
    top_kind = max(end_counts, key=end_counts.get) if sents else ""
    top_ratio = (end_counts[top_kind] / len(sents)) if sents else 0.0

    emo_hits = sum(text.count(w) for w in EMOTION)
    emo_rate = emo_hits * per1k

    found_selfneg = [w for w in SELF_NEGATION if w in text]
    if found_selfneg:
        issues.append(f"自分の説明を打ち消す言い方: {' / '.join(found_selfneg)}"
                      "…本文で根拠を書いているなら矛盾する。書いていないなら残してよい")
    if top_ratio > LIMIT_ENDING_TOP:
        issues.append(f"文末が単調（「{top_kind}」が {top_ratio*100:.0f}% / 目安 {LIMIT_ENDING_TOP*100:.0f}%未満）"
                      "…次の行の形が読む前に分かると、飛ばされる")
    if emo_rate < MIN_EMOTION:
        issues.append(f"感情・主観の語が少ない（1,000字あたり {emo_rate:.1f} 回 / 目安 {MIN_EMOTION}以上）"
                      "…事実と教訓だけになっていないか。書いた人が何を思ったかが要る")
    if len(found_order) > LIMIT_ORDER_KINDS:
        issues.append(f"段落の頭に順序の接続語が{len(found_order)}種類: {' / '.join(found_order)}"
                      "…手順を並べる形になっている。文の中身でつなぐ")

    print(f"\n■ {Path(path).name}（本文 {chars:,}字）")
    if not issues:
        print("  AIっぽさの指摘なし")
    for i in issues:
        print(f"  - {i}")

    if verbose and bold:
        print(f"  強調している箇所（{len(bold)}件）:")
        for b in bold:
            print(f"      **{b}**")

    return m, issues


def main():
    ap = argparse.ArgumentParser(description="記事のAIっぽさを機械的に測る")
    ap.add_argument("files", nargs="+", help="対象の .md")
    ap.add_argument("-v", "--verbose", action="store_true", help="指摘箇所も表示する")
    args = ap.parse_args()

    total = 0
    for f in args.files:
        r = analyze(f, args.verbose)
        if r:
            total += len(r[1])

    print(f"\n合計 {total} 件の指摘")
    sys.exit(1 if total else 0)


if __name__ == "__main__":
    main()
