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())