changelog_from_update_log.py ダウンロード/コピー

changelog_from_update_log.py をダウンロード

changelog_from_update_log.py
changelog_from_update_log.py
   1#!/usr/bin/env python3
   2# -*- coding: utf-8 -*-
   3
   4"""
   5概要:
   6    changelog_from_update_log.py
   7詳細説明:
   8    compare_update_diff.py のログから updated ファイルの相対パスだけを抽出し、
   9    root_dir1 / root_dir2 にある新版・旧版ソースコードそのものを生成AIへ渡して
  10    変更内容を解析し、Markdown ChangeLog を生成します。
  11主な機能:
  12    ログ中の diff 本文は一切使用しません。
  13    created は新版ソースだけをAIで解析し、機能概要を数行でChangeLogへ掲載します。
  14    deleted はAI解析せず、最終ChangeLogへ機械的に掲載します。
  15    updated のみ、旧版と新版のソースコードを比較します。
  16    ソースが大きい場合は、各バージョンをソースコードのまま分割してAIに要約し、
  17    その要約同士を比較します。
  18    AIアクセスには tkai_lib_litellm.py の read_ai_config, query_ai_compatible,
  19    extract_text を使用します。
  20実行モード:
  21    1) ログモード(位置引数1個)
  22    compare_update_diff.py のログから updated / created / deleted を処理します。
  23    python changelog_from_update_log.py update.log
  24    2) 直接比較モード(位置引数2個)
  25    OLD_FILE と NEW_FILE のソースコードを直接比較してChangeLogを作ります。
  26    python changelog_from_update_log.py OLD_FILE NEW_FILE
  27使用例:
  28    python changelog_from_update_log.py update.log -o CHANGELOG.md
  29    python changelog_from_update_log.py old/program.py new/program.py -o CHANGELOG.md
  30    python changelog_from_update_log.py update.log --provider gemini --model gemini-3.1-pro-preview
  31    AIを呼ばずに確認する場合は dry-run を指定します。
  32    python changelog_from_update_log.py update.log --dry-run
  33    python changelog_from_update_log.py old/program.py new/program.py --dry-run
  34"""
  35
  36import argparse
  37import importlib.util
  38import os
  39import re
  40import sys
  41from dataclasses import dataclass
  42from datetime import datetime
  43from pathlib import Path
  44
  45
  46STATUS_RE = re.compile(
  47    r"^(?P<path>.+?):\s+(?P<status>created|updated|deleted)\s+\((?P<detail>.*)\)\s*$"
  48)
  49
  50ROOT1_RE = re.compile(r"^root_dir1:\s*(?P<path>.+?)\s*$")
  51ROOT2_RE = re.compile(r"^root_dir2:\s*(?P<path>.+?)\s*$")
  52ROOT1_LAST_RE = re.compile(
  53    r"^root_dir1 was lastly updated on\s+(?P<date>.+?)\s*$"
  54)
  55
  56
  57@dataclass
  58class UpdateEntry:
  59    """
  60    概要:
  61        更新ログのエントリを保持するデータクラスです。
  62    詳細説明:
  63        ファイルパスと詳細ステータスを管理します。
  64    引数:
  65        :param path: ファイルの相対パス。
  66        :type path: str
  67        :param detail: ログ内の詳細情報。
  68        :type detail: str
  69    """
  70    path: str
  71    detail: str
  72
  73
  74def read_text_file(path):
  75    """
  76    概要:
  77        テキストファイルを読み込みます。
  78    詳細説明:
  79        UTF-8系を優先し、Windows CP932にも対応してテキストを読み込みます。
  80        デコードエラー時は置換モードで読み込みます。
  81    引数:
  82        :param path: 読み込むファイルのパス。
  83        :type path: str または pathlib.Path
  84    戻り値:
  85        :returns: 読み込んだテキストとエンコーディング名のタプル。
  86        :rtype: tuple
  87    """
  88    path = Path(path)
  89
  90    for enc in ("utf-8-sig", "utf-8", "cp932"):
  91        try:
  92            return path.read_text(encoding=enc), enc
  93        except UnicodeDecodeError:
  94            pass
  95
  96    return path.read_text(
  97        encoding="utf-8",
  98        errors="replace"
  99    ), "utf-8(replace)"
 100
 101
 102def parse_update_log(log_text):
 103    """
 104    概要:
 105        ログテキストから必要な情報を抽出します。
 106    詳細説明:
 107        diff本文は無視し、root_dir1、root_dir2、最終更新日時、
 108        ステータス別のファイルエントリを抽出します。
 109    引数:
 110        :param log_text: 解析対象のログ文字列。
 111        :type log_text: str
 112    戻り値:
 113        :returns: 抽出した情報を含む辞書。
 114        :rtype: dict
 115    """
 116    root_dir1 = None
 117    root_dir2 = None
 118    root1_last_updated = None
 119    updated_entries = []
 120    created_entries = []
 121    deleted_entries = []
 122
 123    for line in log_text.splitlines():
 124        m = ROOT1_RE.match(line)
 125        if m:
 126            root_dir1 = m.group("path").strip()
 127            continue
 128
 129        m = ROOT2_RE.match(line)
 130        if m:
 131            root_dir2 = m.group("path").strip()
 132            continue
 133
 134        m = ROOT1_LAST_RE.match(line)
 135        if m:
 136            root1_last_updated = m.group("date").strip()
 137            continue
 138
 139        m = STATUS_RE.match(line)
 140        if not m:
 141            continue
 142
 143        entry = UpdateEntry(
 144            path=m.group("path").strip(),
 145            detail=m.group("detail").strip(),
 146        )
 147
 148        status = m.group("status")
 149        if status == "updated":
 150            updated_entries.append(entry)
 151        elif status == "created":
 152            created_entries.append(entry)
 153        elif status == "deleted":
 154            deleted_entries.append(entry)
 155
 156    return {
 157        "root_dir1": root_dir1,
 158        "root_dir2": root_dir2,
 159        "root1_last_updated": root1_last_updated,
 160        "updated": updated_entries,
 161        "created": created_entries,
 162        "deleted": deleted_entries,
 163    }
 164
 165
 166def relative_to_local_path(rel):
 167    """
 168    概要:
 169        相対パスを実行OSのPathオブジェクトに安全に変換します。
 170    詳細説明:
 171        ログの相対パスはスラッシュ区切りで出力されるため、環境に合わせて処理します。
 172    引数:
 173        :param rel: 変換元の相対パス文字列。
 174        :type rel: str
 175    戻り値:
 176        :returns: 実行環境に応じたPathオブジェクト。
 177        :rtype: pathlib.Path
 178    """
 179    parts = [p for p in rel.replace("\\", "/").split("/") if p]
 180    return Path(*parts)
 181
 182
 183def resolve_source_paths(root_dir1, root_dir2, entry):
 184    """
 185    概要:
 186        旧版と新版のファイルパスを解決します。
 187    詳細説明:
 188        ルートディレクトリとログエントリからそれぞれの絶対パスまたはルートからのパスを生成します。
 189    引数:
 190        :param root_dir1: 新版のルートディレクトリ。
 191        :type root_dir1: pathlib.Path
 192        :param root_dir2: 旧版のルートディレクトリ。
 193        :type root_dir2: pathlib.Path
 194        :param entry: ファイルの更新エントリ。
 195        :type entry: UpdateEntry
 196    戻り値:
 197        :returns: 新版パスと旧版パスのタプル。
 198        :rtype: tuple
 199    """
 200    rel = relative_to_local_path(entry.path)
 201    return root_dir1 / rel, root_dir2 / rel
 202
 203
 204def find_ai_library(explicit_path=None):
 205    """
 206    概要:
 207        tkai_lib_litellm.py を探します。
 208    詳細説明:
 209        指定パス、スクリプトのディレクトリ、カレントディレクトリの順に検索します。
 210    引数:
 211        :param explicit_path: 明示的に指定されたAIライブラリのパス。
 212        :type explicit_path: str または None
 213    戻り値:
 214        :returns: 見つかったライブラリのPathオブジェクト。
 215        :rtype: pathlib.Path
 216    例外:
 217        :raises FileNotFoundError: AIライブラリが見つからない場合に発生します。
 218    """
 219    candidates = []
 220
 221    if explicit_path:
 222        candidates.append(Path(explicit_path).expanduser())
 223
 224    script_dir = Path(__file__).resolve().parent
 225    cwd = Path.cwd()
 226
 227    for base in (script_dir, cwd):
 228        candidates.append(base / "tkai_lib_litellm.py")
 229        candidates.append(base / "tkai_lib_litellm(5).py")
 230
 231    checked = []
 232    for path in candidates:
 233        path = path.resolve()
 234        checked.append(str(path))
 235        if path.is_file():
 236            return path
 237
 238    raise FileNotFoundError(
 239        "tkai_lib_litellm.py が見つかりません。\n"
 240        "--ai-lib PATH で指定してください。\n"
 241        "searched:\n  " + "\n  ".join(checked)
 242    )
 243
 244
 245def load_ai_library(path):
 246    """
 247    概要:
 248        AIライブラリモジュールを動的に読み込みます。
 249    詳細説明:
 250        任意ファイル名の tkai_lib_litellm を動的にロードし、必須関数が存在するか確認します。
 251    引数:
 252        :param path: AIライブラリのパス。
 253        :type path: pathlib.Path
 254    戻り値:
 255        :returns: 読み込んだモジュールオブジェクト。
 256        :rtype: module
 257    例外:
 258        :raises ImportError: モジュールの読み込みに失敗した場合に発生します。
 259        :raises AttributeError: 必要な関数がモジュールに存在しない場合に発生します。
 260    """
 261    path = Path(path).resolve()
 262
 263    spec = importlib.util.spec_from_file_location(
 264        "tkai_lib_litellm_runtime",
 265        path,
 266    )
 267    if spec is None or spec.loader is None:
 268        raise ImportError(f"AI library を読み込めません: {path}")
 269
 270    module = importlib.util.module_from_spec(spec)
 271    spec.loader.exec_module(module)
 272
 273    required = (
 274        "read_ai_config",
 275        "query_ai_compatible",
 276        "extract_text",
 277    )
 278    missing = [name for name in required if not hasattr(module, name)]
 279    if missing:
 280        raise AttributeError(
 281            "AI library に必要な関数がありません: "
 282            + ", ".join(missing)
 283        )
 284
 285    return module
 286
 287
 288def call_ai(
 289    ai,
 290    prompt,
 291    model,
 292    provider,
 293    role,
 294    temperature=None,
 295    reasoning_effort=None,
 296):
 297    """
 298    概要:
 299        生成AIを呼び出し、テキスト応答を取得します。
 300    詳細説明:
 301        与えられたプロンプトと設定でAIにリクエストを送信し、結果のテキストを抽出します。
 302    引数:
 303        :param ai: 読み込んだAIライブラリモジュール。
 304        :type ai: module
 305        :param prompt: AIに渡すプロンプト。
 306        :type prompt: str
 307        :param model: 使用するモデル名。
 308        :type model: str
 309        :param provider: AIのプロバイダ名。
 310        :type provider: str
 311        :param role: システムプロンプトとしての役割指示。
 312        :type role: str
 313        :param temperature: 生成の温度パラメータ。
 314        :type temperature: float または None
 315        :param reasoning_effort: 推論の度合いを指定するパラメータ。
 316        :type reasoning_effort: str または None
 317    戻り値:
 318        :returns: 生成AIから返されたテキスト。
 319        :rtype: str
 320    例外:
 321        :raises RuntimeError: テキスト応答を取得できなかった場合に発生します。
 322    """
 323    response = ai.query_ai_compatible(
 324        prompt=prompt,
 325        model=model,
 326        provider=provider,
 327        role=role,
 328        temperature=temperature,
 329        reasoning_effort=reasoning_effort,
 330    )
 331
 332    text = ai.extract_text(response)
 333    if not text:
 334        raise RuntimeError("生成AIからテキスト応答を取得できませんでした")
 335
 336    return text.strip()
 337
 338
 339def split_source_text(text, max_chars, overlap_lines=10):
 340    """
 341    概要:
 342        大きなソースコードを行単位で分割します。
 343    詳細説明:
 344        各チャンクには少量の行オーバーラップを持たせ、関数境界付近の文脈を失いにくくします。
 345    引数:
 346        :param text: 分割対象のソースコード。
 347        :type text: str
 348        :param max_chars: 1チャンクあたりの最大文字数。
 349        :type max_chars: int
 350        :param overlap_lines: 重複させる行数。
 351        :type overlap_lines: int
 352    戻り値:
 353        :returns: 分割された文字列のリスト。
 354        :rtype: list
 355    """
 356    if max_chars <= 0 or len(text) <= max_chars:
 357        return [text]
 358
 359    lines = text.splitlines(keepends=True)
 360    chunks = []
 361    i = 0
 362
 363    while i < len(lines):
 364        current = []
 365        size = 0
 366        start_i = i
 367
 368        while i < len(lines):
 369            line = lines[i]
 370
 371            if current and size + len(line) > max_chars:
 372                break
 373
 374            current.append(line)
 375            size += len(line)
 376            i += 1
 377
 378        chunks.append("".join(current))
 379
 380        if i >= len(lines):
 381            break
 382
 383        # 少し戻して次chunkとの文脈を重ねる。
 384        i = max(start_i + 1, i - max(0, overlap_lines))
 385
 386    return chunks
 387
 388
 389def summarize_source_chunk(
 390    ai,
 391    source_text,
 392    path,
 393    version_label,
 394    chunk_index,
 395    chunk_count,
 396    model,
 397    provider,
 398    language,
 399    temperature=None,
 400    reasoning_effort=None,
 401):
 402    """
 403    概要:
 404        ソースコードの1チャンクを要約します。
 405    詳細説明:
 406        後で旧版と新版を比較するための中間要約を作成します。言語設定によりプロンプトが切り替わります。
 407    引数:
 408        :param ai: AIライブラリモジュール。
 409        :type ai: module
 410        :param source_text: 対象となるチャンクのソースコード。
 411        :type source_text: str
 412        :param path: ファイルパス。
 413        :type path: str
 414        :param version_label: 旧版か新版かを示すラベル。
 415        :type version_label: str
 416        :param chunk_index: 現在のチャンク番号。
 417        :type chunk_index: int
 418        :param chunk_count: 全チャンク数。
 419        :type chunk_count: int
 420        :param model: AIモデル名。
 421        :type model: str
 422        :param provider: プロバイダ名。
 423        :type provider: str
 424        :param language: 出力言語 (ja または en)。
 425        :type language: str
 426        :param temperature: 生成の温度パラメータ。
 427        :type temperature: float または None
 428        :param reasoning_effort: 推論の度合いを指定するパラメータ。
 429        :type reasoning_effort: str または None
 430    戻り値:
 431        :returns: チャンクの要約テキスト。
 432        :rtype: str
 433    """
 434    if language == "ja":
 435        role = (
 436            "あなたはソフトウェアのソースコードを正確に読む技術アナリストです。"
 437            "コードから確認できる内容だけを要約し、推測しません。"
 438        )
 439        prompt = f"""次のソースコードは `{path}` の {version_label} です。
 440chunk {chunk_index}/{chunk_count} を解析してください。
 441
 442後で旧版と新版を比較するための中間要約を作ります。
 443このchunkについて、次を具体的に箇条書きしてください。
 444
 445- 実装されている主要機能
 446- CLI引数・入出力・外部API
 447- 重要な関数・クラスと役割
 448- エラー処理や重要なアルゴリズム
 449- 他chunkとの比較に役立つ特徴
 450
 451単なる一般論は書かず、コードで確認できる内容だけにしてください。
 452
 453--- source: {version_label} / {path} ---
 454{source_text}
 455--- end source ---
 456"""
 457    else:
 458        role = (
 459            "You are a precise source-code analyst. "
 460            "Describe only behavior supported by the supplied code."
 461        )
 462        prompt = f"""Analyze chunk {chunk_index}/{chunk_count} of the {version_label}
 463version of `{path}`.
 464
 465Create an intermediate summary for later old-vs-new comparison.
 466Include concrete features, CLI/API, I/O, important functions/classes,
 467error handling, and algorithms visible in this chunk.
 468Do not speculate.
 469
 470--- source: {version_label} / {path} ---
 471{source_text}
 472--- end source ---
 473"""
 474
 475    return call_ai(
 476        ai,
 477        prompt,
 478        model,
 479        provider,
 480        role,
 481        temperature=temperature,
 482        reasoning_effort=reasoning_effort,
 483    )
 484
 485
 486def summarize_large_source(
 487    ai,
 488    text,
 489    path,
 490    version_label,
 491    chunk_chars,
 492    model,
 493    provider,
 494    language,
 495    temperature=None,
 496    reasoning_effort=None,
 497):
 498    """
 499    概要:
 500        大きなソースコードを分割して要約し、結果を統合します。
 501    詳細説明:
 502        ファイルを複数チャンクに分割してそれぞれ要約した後、AIを用いて一つの要約に統合します。
 503    引数:
 504        :param ai: AIライブラリモジュール。
 505        :type ai: module
 506        :param text: 全体のソースコードテキスト。
 507        :type text: str
 508        :param path: ファイルパス。
 509        :type path: str
 510        :param version_label: 旧版か新版かを示すラベル。
 511        :type version_label: str
 512        :param chunk_chars: チャンク分割の目安となる文字数。
 513        :type chunk_chars: int
 514        :param model: AIモデル名。
 515        :type model: str
 516        :param provider: プロバイダ名。
 517        :type provider: str
 518        :param language: 出力言語 (ja または en)。
 519        :type language: str
 520        :param temperature: 生成の温度パラメータ。
 521        :type temperature: float または None
 522        :param reasoning_effort: 推論の度合いを指定するパラメータ。
 523        :type reasoning_effort: str または None
 524    戻り値:
 525        :returns: 統合されたソースコード全体の要約。
 526        :rtype: str
 527    """
 528    chunks = split_source_text(
 529        text,
 530        max_chars=chunk_chars,
 531        overlap_lines=10,
 532    )
 533
 534    summaries = []
 535
 536    for i, chunk in enumerate(chunks, start=1):
 537        print(
 538            f"      {version_label} chunk {i}/{len(chunks)} "
 539            f"({len(chunk):,} chars)",
 540            flush=True,
 541        )
 542
 543        summaries.append(
 544            summarize_source_chunk(
 545                ai=ai,
 546                source_text=chunk,
 547                path=path,
 548                version_label=version_label,
 549                chunk_index=i,
 550                chunk_count=len(chunks),
 551                model=model,
 552                provider=provider,
 553                language=language,
 554                temperature=temperature,
 555                reasoning_effort=reasoning_effort,
 556            )
 557        )
 558
 559    joined = "\n\n".join(
 560        f"[chunk {i + 1}]\n{text}"
 561        for i, text in enumerate(summaries)
 562    )
 563
 564    if language == "ja":
 565        role = (
 566            "あなたは複数chunkのソースコード要約を統合する技術編集者です。"
 567            "入力にない内容は追加しません。"
 568        )
 569        prompt = f"""`{path}` の {version_label} ソースコードを分割解析した結果です。
 570
 571重複を除き、後で旧版・新版を比較できるように統合してください。
 572主要機能、CLI/API、入出力、アルゴリズム、エラー処理などの具体性を残してください。
 573
 574{joined}
 575"""
 576    else:
 577        role = (
 578            "You merge chunk-level source summaries without adding unsupported facts."
 579        )
 580        prompt = f"""Merge these summaries for the {version_label} version of `{path}`.
 581Preserve concrete functionality, CLI/API, I/O, algorithms, and error handling
 582so that this can be compared with another version later.
 583
 584{joined}
 585"""
 586
 587    return call_ai(
 588        ai,
 589        prompt,
 590        model,
 591        provider,
 592        role,
 593        temperature=temperature,
 594        reasoning_effort=reasoning_effort,
 595    )
 596
 597
 598def compare_full_sources(
 599    ai,
 600    entry,
 601    old_text,
 602    new_text,
 603    model,
 604    provider,
 605    language,
 606    temperature=None,
 607    reasoning_effort=None,
 608):
 609    """
 610    概要:
 611        旧版・新版ソースの全文を直接比較して変更点を解析します。
 612    詳細説明:
 613        意味のある変更だけを抽出し、Markdownの箇条書き形式で結果を返します。
 614    引数:
 615        :param ai: AIライブラリモジュール。
 616        :type ai: module
 617        :param entry: ファイルの更新エントリ。
 618        :type entry: UpdateEntry
 619        :param old_text: 旧版のソースコードテキスト。
 620        :type old_text: str
 621        :param new_text: 新版のソースコードテキスト。
 622        :type new_text: str
 623        :param model: AIモデル名。
 624        :type model: str
 625        :param provider: プロバイダ名。
 626        :type provider: str
 627        :param language: 出力言語 (ja または en)。
 628        :type language: str
 629        :param temperature: 生成の温度パラメータ。
 630        :type temperature: float または None
 631        :param reasoning_effort: 推論の度合いを指定するパラメータ。
 632        :type reasoning_effort: str または None
 633    戻り値:
 634        :returns: 比較結果のMarkdownテキスト。
 635        :rtype: str
 636    """
 637    if language == "ja":
 638        role = (
 639            "あなたはソフトウェアの変更履歴を作成する技術アナリストです。"
 640            "旧版と新版のソースコードを比較し、実際に変わった内容だけを報告します。"
 641            "コードから確認できない推測は禁止です。"
 642        )
 643        prompt = f"""同じファイルの旧版と新版を比較し、意味のある変更だけを解析してください。
 644
 645対象ファイル: `{entry.path}`
 646更新情報: {entry.detail}
 647
 648重要:
 649- 現在のプログラム全体の機能説明ではなく、旧版から新版への「変更点」を出す
 650- 行番号や単純な位置移動は変更として扱わない
 651- 単なる整形、空白、import順序変更は原則無視
 652- 新機能、動作変更、バグ修正、CLI/API変更、入出力変更を優先
 653- 内部リファクタリングは利用者や保守に意味がある場合だけ記載
 654- 旧版と新版の両方から確認できる事実だけを使う
 655- 2~10個程度の簡潔なMarkdown箇条書きで出力する
 656
 657--- OLD SOURCE ---
 658{old_text}
 659--- END OLD SOURCE ---
 660
 661--- NEW SOURCE ---
 662{new_text}
 663--- END NEW SOURCE ---
 664"""
 665    else:
 666        role = (
 667            "You are a software change analyst. Compare old and new source code "
 668            "and report only actual changes supported by both versions."
 669        )
 670        prompt = f"""Compare the old and new versions of `{entry.path}`.
 671
 672Update metadata: {entry.detail}
 673
 674Report only meaningful changes from old to new.
 675Ignore line-number shifts, code movement, formatting, whitespace, and import
 676ordering unless behavior changes. Prioritize features, behavior, bug fixes,
 677CLI/API, and I/O changes. Return 2-10 concise Markdown bullets.
 678
 679--- OLD SOURCE ---
 680{old_text}
 681--- END OLD SOURCE ---
 682
 683--- NEW SOURCE ---
 684{new_text}
 685--- END NEW SOURCE ---
 686"""
 687
 688    return call_ai(
 689        ai,
 690        prompt,
 691        model,
 692        provider,
 693        role,
 694        temperature=temperature,
 695        reasoning_effort=reasoning_effort,
 696    )
 697
 698
 699def compare_source_summaries(
 700    ai,
 701    entry,
 702    old_summary,
 703    new_summary,
 704    model,
 705    provider,
 706    language,
 707    temperature=None,
 708    reasoning_effort=None,
 709):
 710    """
 711    概要:
 712        非常に大きなソースコード向けに、各版の要約を比較します。
 713    詳細説明:
 714        旧版と新版の要約テキストを受け取り、差分だけを抽出して報告します。
 715    引数:
 716        :param ai: AIライブラリモジュール。
 717        :type ai: module
 718        :param entry: ファイルの更新エントリ。
 719        :type entry: UpdateEntry
 720        :param old_summary: 旧版の要約テキスト。
 721        :type old_summary: str
 722        :param new_summary: 新版の要約テキスト。
 723        :type new_summary: str
 724        :param model: AIモデル名。
 725        :type model: str
 726        :param provider: プロバイダ名。
 727        :type provider: str
 728        :param language: 出力言語 (ja または en)。
 729        :type language: str
 730        :param temperature: 生成の温度パラメータ。
 731        :type temperature: float または None
 732        :param reasoning_effort: 推論の度合いを指定するパラメータ。
 733        :type reasoning_effort: str または None
 734    戻り値:
 735        :returns: 比較結果のMarkdownテキスト。
 736        :rtype: str
 737    """
 738    if language == "ja":
 739        role = (
 740            "あなたはソフトウェア変更履歴の技術アナリストです。"
 741            "旧版・新版のソースコード要約を比較し、差分だけを報告します。"
 742        )
 743        prompt = f"""`{entry.path}` の旧版・新版ソースコードを分割解析した要約です。
 744
 745旧版から新版への意味のある変更だけを2~10個程度のMarkdown箇条書きにしてください。
 746
 747- 共通して存在する機能はChangeLogに書かない
 748- 新機能、動作変更、バグ修正、CLI/API、入出力変更を優先
 749- 単なるコード整理は重要な場合だけ
 750- 要約に根拠のない変更を推測しない
 751
 752--- OLD SUMMARY ---
 753{old_summary}
 754--- END OLD SUMMARY ---
 755
 756--- NEW SUMMARY ---
 757{new_summary}
 758--- END NEW SUMMARY ---
 759"""
 760    else:
 761        role = (
 762            "You compare old/new source summaries and report only supported changes."
 763        )
 764        prompt = f"""Compare these summaries of `{entry.path}` and return 2-10 concise
 765Markdown bullets describing only meaningful changes from old to new.
 766Do not report functionality present in both versions.
 767
 768--- OLD SUMMARY ---
 769{old_summary}
 770--- END OLD SUMMARY ---
 771
 772--- NEW SUMMARY ---
 773{new_summary}
 774--- END NEW SUMMARY ---
 775"""
 776
 777    return call_ai(
 778        ai,
 779        prompt,
 780        model,
 781        provider,
 782        role,
 783        temperature=temperature,
 784        reasoning_effort=reasoning_effort,
 785    )
 786
 787
 788def analyze_updated_file(
 789    ai,
 790    entry,
 791    old_text,
 792    new_text,
 793    model,
 794    provider,
 795    language,
 796    max_direct_chars,
 797    source_chunk_chars,
 798    temperature=None,
 799    reasoning_effort=None,
 800):
 801    """
 802    概要:
 803        更新されたファイルを解析します。
 804    詳細説明:
 805        ファイルサイズに応じて、直接比較するか、チャンク分割して要約後に比較するかを決定します。
 806    引数:
 807        :param ai: AIライブラリモジュール。
 808        :type ai: module
 809        :param entry: ファイルの更新エントリ。
 810        :type entry: UpdateEntry
 811        :param old_text: 旧版のソースコードテキスト。
 812        :type old_text: str
 813        :param new_text: 新版のソースコードテキスト。
 814        :type new_text: str
 815        :param model: AIモデル名。
 816        :type model: str
 817        :param provider: プロバイダ名。
 818        :type provider: str
 819        :param language: 出力言語 (ja または en)。
 820        :type language: str
 821        :param max_direct_chars: 直接比較を許容する最大文字数。
 822        :type max_direct_chars: int
 823        :param source_chunk_chars: 分割時のチャンク文字数。
 824        :type source_chunk_chars: int
 825        :param temperature: 生成の温度パラメータ。
 826        :type temperature: float または None
 827        :param reasoning_effort: 推論の度合いを指定するパラメータ。
 828        :type reasoning_effort: str または None
 829    戻り値:
 830        :returns: 変更点の解析結果。
 831        :rtype: str
 832    """
 833    total_chars = len(old_text) + len(new_text)
 834
 835    if total_chars <= max_direct_chars:
 836        print(
 837            f"    direct source comparison: "
 838            f"old={len(old_text):,}, new={len(new_text):,} chars",
 839            flush=True,
 840        )
 841        return compare_full_sources(
 842            ai=ai,
 843            entry=entry,
 844            old_text=old_text,
 845            new_text=new_text,
 846            model=model,
 847            provider=provider,
 848            language=language,
 849            temperature=temperature,
 850            reasoning_effort=reasoning_effort,
 851        )
 852
 853    print(
 854        f"    large source: old={len(old_text):,}, new={len(new_text):,} chars",
 855        flush=True,
 856    )
 857    print("    analyze source chunks first...", flush=True)
 858
 859    old_summary = summarize_large_source(
 860        ai=ai,
 861        text=old_text,
 862        path=entry.path,
 863        version_label="OLD",
 864        chunk_chars=source_chunk_chars,
 865        model=model,
 866        provider=provider,
 867        language=language,
 868        temperature=temperature,
 869        reasoning_effort=reasoning_effort,
 870    )
 871
 872    new_summary = summarize_large_source(
 873        ai=ai,
 874        text=new_text,
 875        path=entry.path,
 876        version_label="NEW",
 877        chunk_chars=source_chunk_chars,
 878        model=model,
 879        provider=provider,
 880        language=language,
 881        temperature=temperature,
 882        reasoning_effort=reasoning_effort,
 883    )
 884
 885    return compare_source_summaries(
 886        ai=ai,
 887        entry=entry,
 888        old_summary=old_summary,
 889        new_summary=new_summary,
 890        model=model,
 891        provider=provider,
 892        language=language,
 893        temperature=temperature,
 894        reasoning_effort=reasoning_effort,
 895    )
 896
 897
 898
 899
 900def summarize_created_source_direct(
 901    ai,
 902    entry,
 903    source_text,
 904    model,
 905    provider,
 906    language,
 907    temperature=None,
 908    reasoning_effort=None,
 909):
 910    """
 911    概要:
 912        新規作成されたファイルのソースコードから機能説明を生成します。
 913    詳細説明:
 914        公開ChangeLog向けに、プログラムの目的や入出力を簡潔にまとめます。
 915    引数:
 916        :param ai: AIライブラリモジュール。
 917        :type ai: module
 918        :param entry: ファイルの更新エントリ。
 919        :type entry: UpdateEntry
 920        :param source_text: 新規ファイルのソースコードテキスト。
 921        :type source_text: str
 922        :param model: AIモデル名。
 923        :type model: str
 924        :param provider: プロバイダ名。
 925        :type provider: str
 926        :param language: 出力言語 (ja または en)。
 927        :type language: str
 928        :param temperature: 生成の温度パラメータ。
 929        :type temperature: float または None
 930        :param reasoning_effort: 推論の度合いを指定するパラメータ。
 931        :type reasoning_effort: str または None
 932    戻り値:
 933        :returns: 機能説明のMarkdownテキスト。
 934        :rtype: str
 935    """
 936    if language == "ja":
 937        role = (
 938            "あなたは新規公開プログラムの機能をソースコードから説明する技術編集者です。"
 939            "コードで確認できる内容だけを使い、推測しません。"
 940        )
 941        prompt = f"""新規作成されたプログラム `{entry.path}` のソースコードです。
 942
 943公開ChangeLogの "Created" 節に載せる機能概要を作ってください。
 944
 945要件:
 946- Markdown箇条書きのみ
 947- 2~4項目程度
 948- 1項目は1~2文程度
 949- 最初に、このプログラムが何をするものかを簡潔に説明する
 950- 主な入力、出力、CLI、主要機能が分かる場合は重要なものだけ含める
 951- 内部実装の細部より、利用者が何に使えるかを優先する
 952- コードにない内容を推測しない
 953- ファイル名や "新規ファイルを追加" だけで終わらせない
 954
 955--- SOURCE ---
 956{source_text}
 957--- END SOURCE ---
 958"""
 959    else:
 960        role = (
 961            "You are a technical editor describing a newly added public program "
 962            "strictly from its source code."
 963        )
 964        prompt = f"""This is the source code of newly created program `{entry.path}`.
 965
 966Write a short feature description for the ChangeLog "Created" section.
 967
 968Requirements:
 969- Markdown bullets only
 970- About 2-4 bullets
 971- First explain what the program does
 972- Mention important inputs, outputs, CLI, or major features when visible
 973- Prefer user-facing purpose over implementation details
 974- Do not speculate beyond the source
 975
 976--- SOURCE ---
 977{source_text}
 978--- END SOURCE ---
 979"""
 980
 981    return call_ai(
 982        ai,
 983        prompt,
 984        model,
 985        provider,
 986        role,
 987        temperature=temperature,
 988        reasoning_effort=reasoning_effort,
 989    )
 990
 991
 992def condense_created_summary(
 993    ai,
 994    entry,
 995    source_summary,
 996    model,
 997    provider,
 998    language,
 999    temperature=None,
1000    reasoning_effort=None,
1001):
1002    """
1003    概要:
1004        大きな新規ソースの分割解析結果を短いテキストに圧縮します。
1005    詳細説明:
1006        ChangeLogのCreated節に掲載するため、要約テキストから主要機能を抽出します。
1007    引数:
1008        :param ai: AIライブラリモジュール。
1009        :type ai: module
1010        :param entry: ファイルの更新エントリ。
1011        :type entry: UpdateEntry
1012        :param source_summary: 新規ファイルの要約テキスト。
1013        :type source_summary: str
1014        :param model: AIモデル名。
1015        :type model: str
1016        :param provider: プロバイダ名。
1017        :type provider: str
1018        :param language: 出力言語 (ja または en)。
1019        :type language: str
1020        :param temperature: 生成の温度パラメータ。
1021        :type temperature: float または None
1022        :param reasoning_effort: 推論の度合いを指定するパラメータ。
1023        :type reasoning_effort: str または None
1024    戻り値:
1025        :returns: 圧縮された機能説明テキスト。
1026        :rtype: str
1027    """
1028    if language == "ja":
1029        role = (
1030            "あなたは新規公開プログラムのChangeLog説明を簡潔に整える技術編集者です。"
1031            "入力要約にない内容は追加しません。"
1032        )
1033        prompt = f"""新規プログラム `{entry.path}` のソース解析要約です。
1034
1035これを公開ChangeLogの "Created" 節向けに2~4個のMarkdown箇条書きへ圧縮してください。
1036
1037- 最初の項目でプログラムの目的を説明
1038- 利用者に重要な入力・出力・CLI・主要機能を優先
1039- 内部実装の細部は原則省略
1040- 入力にない内容を推測しない
1041
1042--- SUMMARY ---
1043{source_summary}
1044--- END SUMMARY ---
1045"""
1046    else:
1047        role = (
1048            "You condense a source-code summary into a short public ChangeLog description."
1049        )
1050        prompt = f"""Condense this analysis of newly created `{entry.path}` into
10512-4 Markdown bullets for a public ChangeLog.
1052
1053Start with the program purpose, then important user-facing inputs, outputs,
1054CLI, or major features. Do not add unsupported details.
1055
1056--- SUMMARY ---
1057{source_summary}
1058--- END SUMMARY ---
1059"""
1060
1061    return call_ai(
1062        ai,
1063        prompt,
1064        model,
1065        provider,
1066        role,
1067        temperature=temperature,
1068        reasoning_effort=reasoning_effort,
1069    )
1070
1071
1072def analyze_created_file(
1073    ai,
1074    entry,
1075    source_text,
1076    model,
1077    provider,
1078    language,
1079    max_direct_chars,
1080    source_chunk_chars,
1081    temperature=None,
1082    reasoning_effort=None,
1083):
1084    """
1085    概要:
1086        新規作成されたファイルを解析します。
1087    詳細説明:
1088        旧版が存在しないため、新版ソース単体から機能概要を作成します。サイズに応じて直接要約か分割要約を切り替えます。
1089    引数:
1090        :param ai: AIライブラリモジュール。
1091        :type ai: module
1092        :param entry: ファイルの更新エントリ。
1093        :type entry: UpdateEntry
1094        :param source_text: 新規ファイルのソースコードテキスト。
1095        :type source_text: str
1096        :param model: AIモデル名。
1097        :type model: str
1098        :param provider: プロバイダ名。
1099        :type provider: str
1100        :param language: 出力言語 (ja または en)。
1101        :type language: str
1102        :param max_direct_chars: 直接要約を許容する最大文字数。
1103        :type max_direct_chars: int
1104        :param source_chunk_chars: 分割時のチャンク文字数。
1105        :type source_chunk_chars: int
1106        :param temperature: 生成の温度パラメータ。
1107        :type temperature: float または None
1108        :param reasoning_effort: 推論の度合いを指定するパラメータ。
1109        :type reasoning_effort: str または None
1110    戻り値:
1111        :returns: 解析された機能概要テキスト。
1112        :rtype: str
1113    """
1114    if len(source_text) <= max_direct_chars:
1115        print(
1116            f"    created source analysis: {len(source_text):,} chars",
1117            flush=True,
1118        )
1119        return summarize_created_source_direct(
1120            ai=ai,
1121            entry=entry,
1122            source_text=source_text,
1123            model=model,
1124            provider=provider,
1125            language=language,
1126            temperature=temperature,
1127            reasoning_effort=reasoning_effort,
1128        )
1129
1130    print(
1131        f"    large created source: {len(source_text):,} chars",
1132        flush=True,
1133    )
1134
1135    source_summary = summarize_large_source(
1136        ai=ai,
1137        text=source_text,
1138        path=entry.path,
1139        version_label="CREATED",
1140        chunk_chars=source_chunk_chars,
1141        model=model,
1142        provider=provider,
1143        language=language,
1144        temperature=temperature,
1145        reasoning_effort=reasoning_effort,
1146    )
1147
1148    return condense_created_summary(
1149        ai=ai,
1150        entry=entry,
1151        source_summary=source_summary,
1152        model=model,
1153        provider=provider,
1154        language=language,
1155        temperature=temperature,
1156        reasoning_effort=reasoning_effort,
1157    )
1158
1159
1160def changelog_date_heading(root1_last_updated):
1161    """
1162    概要:
1163        更新日時の文字列から YYYY-MM-DD 形式の日付を抽出します。
1164    詳細説明:
1165        新版の最終更新日時を解析し、ChangeLogの日付見出し用文字列を生成します。
1166    引数:
1167        :param root1_last_updated: 抽出元の日付文字列。
1168        :type root1_last_updated: str または None
1169    戻り値:
1170        :returns: フォーマットされた日付文字列、または解析できない場合は None。
1171        :rtype: str または None
1172    """
1173    if not root1_last_updated:
1174        return None
1175
1176    m = re.match(
1177        r"^\s*(\d{4})/(\d{1,2})/(\d{1,2})",
1178        root1_last_updated,
1179    )
1180    if not m:
1181        return None
1182
1183    year, month, day = map(int, m.groups())
1184    return f"{year:04d}-{month:02d}-{day:02d}"
1185
1186
1187
1188def file_mtime_date(path):
1189    """
1190    概要:
1191        ファイルの最終更新日時を YYYY-MM-DD 形式で取得します。
1192    詳細説明:
1193        指定されたファイルのタイムスタンプから日付文字列を生成します。
1194    引数:
1195        :param path: 対象ファイルのパス。
1196        :type path: str または pathlib.Path
1197    戻り値:
1198        :returns: フォーマットされた日付文字列。
1199        :rtype: str
1200    """
1201    dt = datetime.fromtimestamp(Path(path).stat().st_mtime)
1202    return f"{dt.year:04d}-{dt.month:02d}-{dt.day:02d}"
1203
1204
1205def common_relative_suffix(old_path, new_path):
1206    """
1207    概要:
1208        旧版と新版のパスの末尾で一致する部分を相対パスとして返します。
1209    詳細説明:
1210        表示用にパスの共通部分を抽出します。Windowsのパスは大文字小文字を区別せずに比較します。
1211    引数:
1212        :param old_path: 旧版ファイルのパス。
1213        :type old_path: str または pathlib.Path
1214        :param new_path: 新版ファイルのパス。
1215        :type new_path: str または pathlib.Path
1216    戻り値:
1217        :returns: 共通する相対パスの文字列。
1218        :rtype: str
1219    """
1220    old_parts = Path(old_path).parts
1221    new_parts = Path(new_path).parts
1222
1223    common = []
1224    i = 1
1225    while i <= min(len(old_parts), len(new_parts)):
1226        a = old_parts[-i]
1227        b = new_parts[-i]
1228
1229        # Windowsでは大小文字差を無視して比較する方が自然。
1230        if a.casefold() != b.casefold():
1231            break
1232
1233        common.append(b)
1234        i += 1
1235
1236    if not common:
1237        return Path(new_path).name
1238
1239    common.reverse()
1240    return Path(*common).as_posix()
1241
1242
1243def make_created_section(
1244    created_entries,
1245    created_summaries=None,
1246    language="ja",
1247):
1248    """
1249    概要:
1250        ChangeLog用のCreatedセクションを作成します。
1251    詳細説明:
1252        新規作成ファイルのリストと要約をもとに、テキストを組み立てます。
1253    引数:
1254        :param created_entries: 新規ファイルの更新エントリリスト。
1255        :type created_entries: list
1256        :param created_summaries: パスをキーとした要約の辞書。
1257        :type created_summaries: dict または None
1258        :param language: 出力言語 (ja または en)。
1259        :type language: str
1260    戻り値:
1261        :returns: 組み立てられたCreatedセクションのテキスト。
1262        :rtype: str
1263    """
1264    if not created_entries:
1265        return ""
1266
1267    created_summaries = created_summaries or {}
1268    lines = ["# Created"]
1269
1270    for entry in created_entries:
1271        lines.extend(["", f"## `{entry.path}`", "", "### Created"])
1272        summary = created_summaries.get(entry.path, "").strip()
1273
1274        if summary:
1275            lines.append(summary)
1276        elif language == "ja":
1277            lines.append("- 新規ファイルを追加。")
1278        else:
1279            lines.append("- New file added.")
1280
1281    return "\n".join(lines)
1282
1283
1284def make_deleted_section(deleted_entries):
1285    """
1286    概要:
1287        ChangeLog用のDeletedセクションを作成します。
1288    詳細説明:
1289        削除されたファイルのリストをもとに、テキストを組み立てます。
1290    引数:
1291        :param deleted_entries: 削除ファイルの更新エントリリスト。
1292        :type deleted_entries: list
1293    戻り値:
1294        :returns: 組み立てられたDeletedセクションのテキスト。
1295        :rtype: str
1296    """
1297    if not deleted_entries:
1298        return ""
1299
1300    lines = ["# Deleted"]
1301    for entry in deleted_entries:
1302        lines.append(f"- `{entry.path}`")
1303
1304    return "\n".join(lines)
1305
1306
1307def assemble_changelog(
1308    changed_section="",
1309    created_entries=None,
1310    deleted_entries=None,
1311    created_summaries=None,
1312    updated_date=None,
1313    language="ja",
1314):
1315    """
1316    概要:
1317        ChangeLog全体を組み立てます。
1318    詳細説明:
1319        各セクションのテキストとメタデータを結合し、1つの文字列を生成します。
1320    引数:
1321        :param changed_section: 変更されたファイルのセクションテキスト。
1322        :type changed_section: str
1323        :param created_entries: 新規作成されたファイルのエントリリスト。
1324        :type created_entries: list または None
1325        :param deleted_entries: 削除されたファイルのエントリリスト。
1326        :type deleted_entries: list または None
1327        :param created_summaries: 新規ファイルの要約辞書。
1328        :type created_summaries: dict または None
1329        :param updated_date: 更新日時の文字列。
1330        :type updated_date: str または None
1331        :param language: 出力言語 (ja または en)。
1332        :type language: str
1333    戻り値:
1334        :returns: 組み立てられたChangeLogの全体テキスト。
1335        :rtype: str
1336    """
1337    created_entries = created_entries or []
1338    deleted_entries = deleted_entries or []
1339
1340    parts = [
1341        "---",
1342        "---",
1343        "",
1344        "# ChangeLog",
1345    ]
1346
1347    if updated_date:
1348        parts.append(f"Updated: {updated_date}")
1349
1350    sections = []
1351
1352    if changed_section and changed_section.strip():
1353        sections.append(changed_section.strip())
1354
1355    created_section = make_created_section(
1356        created_entries,
1357        created_summaries=created_summaries,
1358        language=language,
1359    )
1360    if created_section:
1361        sections.append(created_section)
1362
1363    deleted_section = make_deleted_section(deleted_entries)
1364    if deleted_section:
1365        sections.append(deleted_section)
1366
1367    for section in sections:
1368        parts.extend(["", "---", "", section])
1369
1370    return "\n".join(parts).rstrip() + "\n"
1371
1372
1373def initialize_ai(args):
1374    """
1375    概要:
1376        AI関連の初期化処理を行います。
1377    詳細説明:
1378        AIライブラリの検索、読み込み、設定のロード、およびモデルとプロバイダの決定を行います。
1379    引数:
1380        :param args: コマンドライン引数を格納したNamespace。
1381        :type args: argparse.Namespace
1382    戻り値:
1383        :returns: 初期化済みのAIモジュール、プロバイダ名、モデル名のタプル。
1384        :rtype: tuple
1385    """
1386    ai_lib_path = find_ai_library(args.ai_lib)
1387    print(f"AI library: {ai_lib_path}")
1388
1389    ai = load_ai_library(ai_lib_path)
1390    ai.read_ai_config(args.config)
1391
1392    provider = (
1393        args.provider
1394        or os.getenv("AI_PROVIDER")
1395        or os.getenv("provider")
1396        or "openai"
1397    )
1398    model = (
1399        args.model
1400        or os.getenv("AI_MODEL")
1401        or os.getenv("model")
1402        or "gpt-4o-mini"
1403    )
1404
1405    print(f"provider: {provider}")
1406    print(f"model   : {model}")
1407    print()
1408
1409    return ai, provider, model
1410
1411
1412def make_machine_sections(
1413    created_entries,
1414    deleted_entries,
1415    created_summaries=None,
1416    language="ja",
1417):
1418    """
1419    概要:
1420        新規作成および削除ファイルのセクションを機械的に生成します。
1421    詳細説明:
1422        AI解析結果がある場合はそれを記載し、ない場合は機械的な追加・削除メッセージを生成します。
1423    引数:
1424        :param created_entries: 新規ファイルのエントリリスト。
1425        :type created_entries: list
1426        :param deleted_entries: 削除ファイルのエントリリスト。
1427        :type deleted_entries: list
1428        :param created_summaries: 新規ファイルの要約辞書。
1429        :type created_summaries: dict または None
1430        :param language: 出力言語 (ja または en)。
1431        :type language: str
1432    戻り値:
1433        :returns: 生成されたセクションのテキスト。
1434        :rtype: str
1435    """
1436    created_summaries = created_summaries or {}
1437    sections = []
1438
1439    for entry in created_entries:
1440        summary = created_summaries.get(entry.path, "").strip()
1441
1442        if summary:
1443            body = f"### Created\n{summary}"
1444        else:
1445            body = (
1446                "### Created\n- 新規ファイルを追加。"
1447                if language == "ja"
1448                else "### Created\n- New file added."
1449            )
1450
1451        sections.append(f"## `{entry.path}`\n\n{body}")
1452
1453    for entry in deleted_entries:
1454        body = (
1455            "### Deleted\n- ファイルを削除。"
1456            if language == "ja"
1457            else "### Deleted\n- File removed."
1458        )
1459        sections.append(f"## `{entry.path}`\n\n{body}")
1460
1461    return "\n\n".join(sections)
1462
1463
1464def append_machine_sections(
1465    changelog,
1466    created_entries,
1467    deleted_entries,
1468    created_summaries=None,
1469    root1_last_updated=None,
1470    language="ja",
1471):
1472    """
1473    概要:
1474        AI生成のChangeLogに機械的なセクションを追加します。
1475    詳細説明:
1476        既存のChangeLogテキストの末尾に、新規作成および削除されたファイルの内容を追記します。
1477    引数:
1478        :param changelog: ベースとなるChangeLogテキスト。
1479        :type changelog: str
1480        :param created_entries: 新規ファイルのエントリリスト。
1481        :type created_entries: list
1482        :param deleted_entries: 削除ファイルのエントリリスト。
1483        :type deleted_entries: list
1484        :param created_summaries: 新規ファイルの要約辞書。
1485        :type created_summaries: dict または None
1486        :param root1_last_updated: 最終更新日時の文字列。
1487        :type root1_last_updated: str または None
1488        :param language: 出力言語 (ja または en)。
1489        :type language: str
1490    戻り値:
1491        :returns: 追記処理が行われたChangeLogテキスト。
1492        :rtype: str
1493    """
1494    machine = make_machine_sections(
1495        created_entries,
1496        deleted_entries,
1497        created_summaries=created_summaries,
1498        language=language,
1499    )
1500
1501    date_heading = changelog_date_heading(root1_last_updated)
1502
1503    if changelog and changelog.strip():
1504        base = changelog.strip()
1505        if date_heading:
1506            lines = base.splitlines()
1507            if lines and lines[0].strip() == "# ChangeLog":
1508                has_updated = any(
1509                    line.strip().lower().startswith("updated:")
1510                    for line in lines[1:5]
1511                )
1512                if not has_updated:
1513                    lines[1:1] = ["", f"Updated: {date_heading}", ""]
1514                base = "\n".join(lines)
1515    else:
1516        lines = ["# ChangeLog"]
1517        if date_heading:
1518            lines.extend(["", f"Updated: {date_heading}"])
1519        base = "\n".join(lines)
1520
1521    if not machine:
1522        return base + "\n"
1523
1524    return base + "\n\n" + machine + "\n"
1525
1526
1527def make_final_changelog(
1528    ai,
1529    file_summaries,
1530    root1_last_updated,
1531    model,
1532    provider,
1533    language,
1534    temperature=None,
1535    reasoning_effort=None,
1536):
1537    """
1538    概要:
1539        最終的なChangedセクションを生成します。
1540    詳細説明:
1541        更新ファイルの解析結果をAIに渡し、フォーマットされたChangedセクションを作成します。
1542    引数:
1543        :param ai: AIライブラリモジュール。
1544        :type ai: module
1545        :param file_summaries: ファイルパスと要約のタプルのリスト。
1546        :type file_summaries: list
1547        :param root1_last_updated: 最終更新日時の文字列。
1548        :type root1_last_updated: str または None
1549        :param model: AIモデル名。
1550        :type model: str
1551        :param provider: プロバイダ名。
1552        :type provider: str
1553        :param language: 出力言語 (ja または en)。
1554        :type language: str
1555        :param temperature: 生成の温度パラメータ。
1556        :type temperature: float または None
1557        :param reasoning_effort: 推論の度合いを指定するパラメータ。
1558        :type reasoning_effort: str または None
1559    戻り値:
1560        :returns: 生成されたChangedセクションのテキスト。
1561        :rtype: str
1562    """
1563    body = "\n\n".join(
1564        f"FILE: {path}\n{summary}"
1565        for path, summary in file_summaries
1566    )
1567
1568    if language == "ja":
1569        role = (
1570            "あなたは公開ソフトウェアのChangeLogを作成する技術編集者です。"
1571            "入力された変更解析だけを根拠にし、推測しません。"
1572        )
1573
1574        prompt = f"""以下は updated ファイルごとの旧版・新版比較結果です。
1575添付ChangeLog形式の Changed セクションをMarkdownで作成してください。
1576
1577出力形式を厳守してください:
1578
1579# Changed
1580
1581## `relative/path/to/file.py`
1582### Added
1583- ...
1584
1585### Changed
1586- ...
1587
1588### Fixed
1589- ...
1590
1591### Internal
1592- ...
1593
1594要件:
1595- 出力は "# Changed" から開始する。
1596- "# ChangeLog" や日付は出力しない。
1597- 各 `##` 見出しには必ず1個の相対ファイルパスを書く。
1598- 複数ファイルを同じ `##` にまとめない。
1599- 各ファイル内では必要な `### Added`, `### Changed`,
1600  `### Fixed`, `### Internal` だけを使う。
1601- 利用者から見た意味のある変更を優先する。
1602- created / deleted はここには含めない。
1603- 入力に根拠のない変更を追加しない。
1604- 前置き説明やコードフェンスは不要。
1605
1606--- analyses ---
1607{body}
1608--- end analyses ---
1609"""
1610    else:
1611        role = (
1612            "You are a technical editor writing a public software changelog "
1613            "strictly from supplied old-vs-new source analyses."
1614        )
1615
1616        prompt = f"""Create only the Changed section of a public Markdown changelog.
1617
1618Required format:
1619
1620# Changed
1621
1622## `relative/path/to/file.py`
1623### Added
1624- ...
1625
1626### Changed
1627- ...
1628
1629### Fixed
1630- ...
1631
1632### Internal
1633- ...
1634
1635Requirements:
1636- Start with "# Changed".
1637- Do not output "# ChangeLog" or a date.
1638- One relative file path per `##` heading.
1639- Never combine multiple files under one `##`.
1640- Use only relevant Added / Changed / Fixed / Internal subsections.
1641- Do not include created/deleted files or unsupported claims.
1642- No introductory prose or code fences.
1643
1644--- analyses ---
1645{body}
1646--- end analyses ---
1647"""
1648
1649    text = call_ai(
1650        ai,
1651        prompt,
1652        model,
1653        provider,
1654        role,
1655        temperature=temperature,
1656        reasoning_effort=reasoning_effort,
1657    ).strip()
1658
1659    # 軽い保険: Markdownコードフェンスが付いた場合だけ除去。
1660    if text.startswith("```") and text.endswith("```"):
1661        lines = text.splitlines()
1662        if lines:
1663            lines = lines[1:]
1664        if lines and lines[-1].strip() == "```":
1665            lines = lines[:-1]
1666        text = "\n".join(lines).strip()
1667
1668    if not text.startswith("# Changed"):
1669        text = "# Changed\n\n" + text
1670
1671    return text
1672
1673
1674def main():
1675    """
1676    概要:
1677        スクリプトのエントリポイントです。
1678    詳細説明:
1679        コマンドライン引数を解析し、ログモードまたは直接比較モードでChangeLogを生成して保存します。
1680    戻り値:
1681        :returns: 終了ステータスコード。
1682        :rtype: int
1683    """
1684    parser = argparse.ArgumentParser(
1685        description=(
1686            "Create a Markdown ChangeLog either from one compare-update log "
1687            "or by directly comparing OLD_FILE and NEW_FILE."
1688        )
1689    )
1690
1691    parser.add_argument(
1692        "inputs",
1693        nargs="+",
1694        help=(
1695            "One argument: compare_update_diff.py log. "
1696            "Two arguments: OLD_FILE NEW_FILE."
1697        ),
1698    )
1699    parser.add_argument(
1700        "-o", "--output",
1701        default="CHANGELOG.md",
1702        help='Output Markdown file (default: "CHANGELOG.md")'
1703    )
1704    parser.add_argument(
1705        "--root-dir1",
1706        default=None,
1707        help="Override root_dir1 written in the log (log mode only)"
1708    )
1709    parser.add_argument(
1710        "--root-dir2",
1711        default=None,
1712        help="Override root_dir2 written in the log (log mode only)"
1713    )
1714    parser.add_argument(
1715        "--ai-lib",
1716        default=None,
1717        help="Path to tkai_lib_litellm.py"
1718    )
1719    parser.add_argument(
1720        "--config",
1721        default="translate.env",
1722        help='AI config env file (default: "translate.env")'
1723    )
1724    parser.add_argument(
1725        "--provider",
1726        default=None,
1727        help='AI provider, e.g. "openai" or "gemini"'
1728    )
1729    parser.add_argument(
1730        "--model",
1731        default=None,
1732        help="Model name. If omitted, AI_MODEL env or gpt-4o-mini is used"
1733    )
1734    parser.add_argument(
1735        "--language",
1736        choices=("ja", "en"),
1737        default="ja",
1738        help='ChangeLog language (default: "ja")'
1739    )
1740    parser.add_argument(
1741        "--max-direct-chars",
1742        type=int,
1743        default=200000,
1744        help=(
1745            "Maximum combined old+new source characters for one direct AI "
1746            "comparison (default: 200000)"
1747        )
1748    )
1749    parser.add_argument(
1750        "--source-chunk-chars",
1751        type=int,
1752        default=40000,
1753        help=(
1754            "Approximate source characters per chunk for very large files "
1755            "(default: 40000)"
1756        )
1757    )
1758    parser.add_argument(
1759        "--temperature",
1760        type=float,
1761        default=None,
1762        help="Optional AI temperature. Default: do not send"
1763    )
1764    parser.add_argument(
1765        "--reasoning-effort",
1766        default=None,
1767        choices=("minimal", "low", "medium", "high"),
1768        help="Optional reasoning_effort for compatible models"
1769    )
1770    parser.add_argument(
1771        "--dry-run",
1772        action="store_true",
1773        help="Show source paths without calling AI"
1774    )
1775
1776    args = parser.parse_args()
1777
1778    if len(args.inputs) not in (1, 2):
1779        parser.error(
1780            "Specify either one log file, or two source files: OLD_FILE NEW_FILE"
1781        )
1782
1783    # =====================================================================
1784    # Direct source comparison mode: OLD_FILE NEW_FILE
1785    # =====================================================================
1786    if len(args.inputs) == 2:
1787        old_path = Path(args.inputs[0]).expanduser().resolve()
1788        new_path = Path(args.inputs[1]).expanduser().resolve()
1789
1790        if not old_path.is_file():
1791            parser.error(f"OLD_FILE is not found: {old_path}")
1792        if not new_path.is_file():
1793            parser.error(f"NEW_FILE is not found: {new_path}")
1794
1795        display_path = common_relative_suffix(old_path, new_path)
1796        updated_date = file_mtime_date(new_path)
1797
1798        print("mode     : direct source comparison")
1799        print(f"OLD      : {old_path}")
1800        print(f"NEW      : {new_path}")
1801        print(f"path     : {display_path}")
1802        print(f"Updated  : {updated_date}")
1803
1804        if args.dry_run:
1805            return 0
1806
1807        ai, provider, model = initialize_ai(args)
1808
1809        old_text, old_enc = read_text_file(old_path)
1810        new_text, new_enc = read_text_file(new_path)
1811
1812        print(f"OLD source: {len(old_text):,} chars, {old_enc}")
1813        print(f"NEW source: {len(new_text):,} chars, {new_enc}")
1814
1815        entry = UpdateEntry(
1816            path=display_path,
1817            detail=f"direct comparison; new file updated on {updated_date}",
1818        )
1819
1820        summary = analyze_updated_file(
1821            ai=ai,
1822            entry=entry,
1823            old_text=old_text,
1824            new_text=new_text,
1825            model=model,
1826            provider=provider,
1827            language=args.language,
1828            max_direct_chars=max(10000, args.max_direct_chars),
1829            source_chunk_chars=max(5000, args.source_chunk_chars),
1830            temperature=args.temperature,
1831            reasoning_effort=args.reasoning_effort,
1832        )
1833
1834        print()
1835        print(summary)
1836        print()
1837
1838        changed_section = make_final_changelog(
1839            ai=ai,
1840            file_summaries=[(entry.path, summary)],
1841            root1_last_updated=None,
1842            model=model,
1843            provider=provider,
1844            language=args.language,
1845            temperature=args.temperature,
1846            reasoning_effort=args.reasoning_effort,
1847        )
1848
1849        changelog = assemble_changelog(
1850            changed_section=changed_section,
1851            updated_date=updated_date,
1852            language=args.language,
1853        )
1854
1855        output_path = Path(args.output).expanduser()
1856        output_path.parent.mkdir(parents=True, exist_ok=True)
1857        output_path.write_text(changelog, encoding="utf-8")
1858
1859        print(changelog)
1860        print(f"Saved: {output_path.resolve()}")
1861        return 0
1862
1863    # =====================================================================
1864    # Log mode: update.log
1865    # =====================================================================
1866    log_path = Path(args.inputs[0]).expanduser().resolve()
1867    if not log_path.is_file():
1868        parser.error(f"log file is not found: {log_path}")
1869
1870    log_text, log_encoding = read_text_file(log_path)
1871    info = parse_update_log(log_text)
1872
1873    root1_text = args.root_dir1 or info["root_dir1"]
1874    root2_text = args.root_dir2 or info["root_dir2"]
1875
1876    if not root1_text:
1877        parser.error("root_dir1 was not found in the log; use --root-dir1")
1878    if not root2_text:
1879        parser.error("root_dir2 was not found in the log; use --root-dir2")
1880
1881    root_dir1 = Path(root1_text).expanduser()
1882    root_dir2 = Path(root2_text).expanduser()
1883
1884    entries = info["updated"]
1885
1886    print("mode     : update-log")
1887    print(f"logfile  : {log_path}")
1888    print(f"encoding : {log_encoding}")
1889    print(f"root_dir1: {root_dir1}")
1890    print(f"root_dir2: {root_dir2}")
1891    print(f"updated  : {len(entries)} files")
1892    print(f"created  : {len(info['created'])} files (AI feature summary)")
1893    print(f"deleted  : {len(info['deleted'])} files (AI analysis skipped)")
1894
1895    resolved = []
1896    errors = []
1897
1898    for entry in entries:
1899        new_path, old_path = resolve_source_paths(
1900            root_dir1,
1901            root_dir2,
1902            entry,
1903        )
1904
1905        exists_new = new_path.is_file()
1906        exists_old = old_path.is_file()
1907
1908        if not exists_new or not exists_old:
1909            errors.append(
1910                (entry, new_path, old_path, exists_new, exists_old)
1911            )
1912
1913        resolved.append(
1914            (entry, new_path, old_path, exists_new, exists_old)
1915        )
1916
1917    created_resolved = []
1918    created_errors = []
1919
1920    for entry in info["created"]:
1921        rel = relative_to_local_path(entry.path)
1922        new_path = root_dir1 / rel
1923        exists_new = new_path.is_file()
1924
1925        if not exists_new:
1926            created_errors.append((entry, new_path))
1927
1928        created_resolved.append((entry, new_path, exists_new))
1929
1930    if args.dry_run:
1931        print()
1932        if resolved:
1933            print("Updated files:")
1934        for i, (entry, new_path, old_path, exists_new, exists_old) in enumerate(
1935            resolved,
1936            start=1,
1937        ):
1938            print(f"[{i}/{len(resolved)}] {entry.path}")
1939            print(f"  OLD: {old_path}  [{'OK' if exists_old else 'NOT FOUND'}]")
1940            print(f"  NEW: {new_path}  [{'OK' if exists_new else 'NOT FOUND'}]")
1941
1942        if info["created"]:
1943            print()
1944            print("Created files:")
1945            for i, (entry, new_path, exists_new) in enumerate(
1946                created_resolved,
1947                start=1,
1948            ):
1949                print(f"[{i}/{len(created_resolved)}] {entry.path}")
1950                print(f"  NEW: {new_path}  [{'OK' if exists_new else 'NOT FOUND'}]")
1951
1952        if info["deleted"]:
1953            print()
1954            print("Deleted files:")
1955            for entry in info["deleted"]:
1956                print(f"  {entry.path}")
1957
1958        if errors:
1959            print()
1960            print(f"Warning: {len(errors)} updated file(s) are incomplete.")
1961        if created_errors:
1962            print(
1963                f"Warning: {len(created_errors)} created file(s) "
1964                "were not found in root_dir1."
1965            )
1966        return 0
1967
1968    if errors:
1969        print()
1970        print("Error: updated file(s) are missing:")
1971        for entry, new_path, old_path, exists_new, exists_old in errors:
1972            print(f"  {entry.path}")
1973            if not exists_old:
1974                print(f"    OLD not found: {old_path}")
1975            if not exists_new:
1976                print(f"    NEW not found: {new_path}")
1977        print()
1978        print("Use --root-dir1/--root-dir2 if the log paths have moved.")
1979        return 2
1980
1981    if created_errors:
1982        print()
1983        print(
1984            "Warning: created source file(s) not found; "
1985            "generic Created entry will be used:"
1986        )
1987        for entry, new_path in created_errors:
1988            print(f"  {entry.path}: {new_path}")
1989        print()
1990
1991    readable_created = [
1992        item for item in created_resolved if item[2]
1993    ]
1994
1995    # AI不要なら機械的にChangeLogだけ作る。
1996    if not entries and not readable_created:
1997        changelog = assemble_changelog(
1998            changed_section="",
1999            created_entries=info["created"],
2000            deleted_entries=info["deleted"],
2001            created_summaries={},
2002            updated_date=changelog_date_heading(info["root1_last_updated"]),
2003            language=args.language,
2004        )
2005
2006        output_path = Path(args.output).expanduser()
2007        output_path.parent.mkdir(parents=True, exist_ok=True)
2008        output_path.write_text(changelog, encoding="utf-8")
2009
2010        print(changelog)
2011        print(f"Saved: {output_path.resolve()}")
2012        return 0
2013
2014    ai, provider, model = initialize_ai(args)
2015
2016    file_summaries = []
2017
2018    for i, (entry, new_path, old_path, _, _) in enumerate(resolved, start=1):
2019        print(f"[{i}/{len(resolved)}] {entry.path}", flush=True)
2020
2021        old_text, old_enc = read_text_file(old_path)
2022        new_text, new_enc = read_text_file(new_path)
2023
2024        print(f"    OLD: {len(old_text):,} chars, {old_enc}", flush=True)
2025        print(f"    NEW: {len(new_text):,} chars, {new_enc}", flush=True)
2026
2027        summary = analyze_updated_file(
2028            ai=ai,
2029            entry=entry,
2030            old_text=old_text,
2031            new_text=new_text,
2032            model=model,
2033            provider=provider,
2034            language=args.language,
2035            max_direct_chars=max(10000, args.max_direct_chars),
2036            source_chunk_chars=max(5000, args.source_chunk_chars),
2037            temperature=args.temperature,
2038            reasoning_effort=args.reasoning_effort,
2039        )
2040
2041        file_summaries.append((entry.path, summary))
2042
2043        print(summary)
2044        print()
2045
2046    created_summaries = {}
2047    readable_created = [
2048        (entry, new_path)
2049        for entry, new_path, exists_new in created_resolved
2050        if exists_new
2051    ]
2052
2053    if readable_created:
2054        print("Analyzing created files...", flush=True)
2055
2056    for i, (entry, new_path) in enumerate(readable_created, start=1):
2057        print(
2058            f"[created {i}/{len(readable_created)}] {entry.path}",
2059            flush=True,
2060        )
2061
2062        new_text, new_enc = read_text_file(new_path)
2063        print(f"    NEW: {len(new_text):,} chars, {new_enc}", flush=True)
2064
2065        summary = analyze_created_file(
2066            ai=ai,
2067            entry=entry,
2068            source_text=new_text,
2069            model=model,
2070            provider=provider,
2071            language=args.language,
2072            max_direct_chars=max(10000, args.max_direct_chars),
2073            source_chunk_chars=max(5000, args.source_chunk_chars),
2074            temperature=args.temperature,
2075            reasoning_effort=args.reasoning_effort,
2076        )
2077
2078        created_summaries[entry.path] = summary
2079
2080        print(summary)
2081        print()
2082
2083    print("Generating final ChangeLog...", flush=True)
2084
2085    if file_summaries:
2086        changed_section = make_final_changelog(
2087            ai=ai,
2088            file_summaries=file_summaries,
2089            root1_last_updated=info["root1_last_updated"],
2090            model=model,
2091            provider=provider,
2092            language=args.language,
2093            temperature=args.temperature,
2094            reasoning_effort=args.reasoning_effort,
2095        )
2096    else:
2097        changed_section = ""
2098
2099    changelog = assemble_changelog(
2100        changed_section=changed_section,
2101        created_entries=info["created"],
2102        deleted_entries=info["deleted"],
2103        created_summaries=created_summaries,
2104        updated_date=changelog_date_heading(info["root1_last_updated"]),
2105        language=args.language,
2106    )
2107
2108    output_path = Path(args.output).expanduser()
2109    output_path.parent.mkdir(parents=True, exist_ok=True)
2110    output_path.write_text(changelog, encoding="utf-8")
2111
2112    print()
2113    print(changelog)
2114    print(f"Saved: {output_path.resolve()}")
2115
2116    return 0
2117
2118
2119if __name__ == "__main__":
2120    raise SystemExit(main())