ChatGPTの回答差分を残すPython監査術
OpenAIは8月6日、ChatGPTのGPT-5.6 Solを更新し、Freeユーザー向けにはGPT-5.6 Lunaを既定にすると発表しました。SolはChat内の応答をより正確で一貫したものにする更新で、Free側もThinkボタンを含む提供範囲が変わります。詳しくは公式発表に出ています。
これが意味するのは、先週ChatGPTに作らせた提案の下書きを、条件を残さず今週の企画書に貼り直す仕事が説明不足になる、ということです。
僕は営業資料のたたき台にChatGPTを使います。ただ、出力が前より良くなったかを「なんとなく読みやすい」で済ませるのは雑です。モデル、考えさせ方、プロンプトの版、取り出した日時。この4つが抜けると、同じ案件名のテキストが2本あっても、なぜ内容が変わったのか追えません。
そこで、回答本文はテキストファイルで保管し、CSVには条件だけを1行ずつ残すようにしています。この記事のPythonは、その履歴を時系列に並べ、本文が変わった行だけをdiffで出します。ChatGPTを自動操作するものではありません。再利用前の確認を速くするための台帳です。
先に結論:回答そのものより「条件」を一緒に残す
モデル更新を歓迎しつつ、業務の下書きでは別の話があります。精度が上がっても、同じ回答が返る保証にはなりません。商談の提案文で変わった一文が、価格条件なのか、競合名なのか、単なる言い回しなのか。そこを見ずに再利用すると、レビューする人だけが困ります。
残す列は6つで足ります。
| 列 | 入れるもの | 例 |
|---|---|---|
job |
何の仕事か | 商談提案 |
captured_at |
回答を保存した日時(タイムゾーン付き) | 2026-08-07T09:15:00+09:00 |
model |
画面で確認したモデル名 | GPT-5.6 Sol |
mode |
通常か、深く考えさせたか |
quick / think
|
prompt_version |
プロンプトの版 | proposal-v3 |
output_file |
回答本文の保存先 | answers/proposal_0807.txt |
model と mode は、回答を取った画面で見えた表記をそのまま入れます。ChatGPTの画面上の表示が取れないなら、空欄のまま保存しない方がいいです。あとで「たぶん同じ条件だった」は、ほぼ役に立ちません。
CSVは answer_log.csv、本文は同じフォルダの answers に置きます。最初はこんな形です。
job,captured_at,model,mode,prompt_version,output_file
商談提案,2026-08-06T16:20:00+09:00,GPT-5.6 Sol,quick,proposal-v3,answers/proposal_0806.txt
商談提案,2026-08-07T09:15:00+09:00,GPT-5.6 Sol,quick,proposal-v3,answers/proposal_0807.txt
定例要約,2026-08-07T09:30:00+09:00,GPT-5.6 Sol,think,meeting-v1,answers/meeting_0807.txt
プロンプト本文をCSVに丸ごと入れないのは、表が読みにくくなるからです。proposal-v3.md のように別ファイルで版を管理し、CSVには版名だけを記録します。変更したら v4 にする。手間に見えますが、後から差分を読む時間より短いです。
このCSVは、回答の優劣を採点する表ではありません。同じ job でも、顧客から追加で聞いた条件や参照した資料が変われば、回答が変わるのは当然です。その変更まで同じプロンプト版に押し込むと、差分を見た人が原因を間違えます。入力にした資料や条件を変えた日は、proposal-v4 のように版を上げます。モデルを替えた時だけ記録する表にしないことが大事です。
逆に、定例の議事録要約のように、毎週ほぼ同じ指示で回す仕事には相性がいいです。前週より固有名詞が減った、担当者が入れ替わった、期限が消えた。その変化を、提出直前でなく保存した時点で拾えます。使う人が目で最終確認する前提は変わりません。確認する場所を先に示せるだけです。
今のところ、これだけで回っています。
回答差分を出すPython
以下を chat_answer_audit.py として保存します。外部ライブラリは使いません。絶対パスや .. を含む保存先を止めるので、CSVを別案件へコピーした時に関係ない回答を読んでしまう事故も避けられます。
import csv
import hashlib
import sys
from datetime import datetime
from difflib import unified_diff
from pathlib import Path
REQUIRED = {
"job", "captured_at", "model", "mode", "prompt_version", "output_file",
}
CONDITION_FIELDS = ("model", "mode", "prompt_version")
def parse_timestamp(value, row_number):
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(
f"{row_number}行目のcaptured_atはISO 8601形式にしてください"
) from exc
if parsed.tzinfo is None:
raise ValueError(f"{row_number}行目のcaptured_atにタイムゾーンがありません")
return parsed
def load_answer(base_dir, value, row_number):
relative_path = Path(value)
if not value or relative_path.is_absolute() or ".." in relative_path.parts:
raise ValueError(
f"{row_number}行目のoutput_fileはCSVと同じフォルダ配下にしてください"
)
path = base_dir / relative_path
try:
answer = path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise ValueError(f"{row_number}行目の回答ファイルがありません: {value}") from exc
if not answer.strip():
raise ValueError(f"{row_number}行目の回答ファイルが空です: {value}")
return answer.replace("\r\n", "\n").replace("\r", "\n")
def read_runs(csv_path):
with csv_path.open(newline="", encoding="utf-8-sig") as source:
reader = csv.DictReader(source)
fields = set(reader.fieldnames or [])
missing = ", ".join(sorted(REQUIRED - fields))
if missing:
raise ValueError(f"監査CSVの列が足りません: {missing}")
runs = []
seen = set()
for row_number, raw in enumerate(reader, start=2):
row = {field: (raw[field] or "").strip() for field in REQUIRED}
blank = [field for field, value in row.items() if not value]
if blank:
raise ValueError(f"{row_number}行目が空です: {', '.join(sorted(blank))}")
captured_at = parse_timestamp(row["captured_at"], row_number)
key = (row["job"], captured_at)
if key in seen:
raise ValueError(f"jobとcaptured_atが重複しています: {row['job']}")
seen.add(key)
answer = load_answer(csv_path.parent, row["output_file"], row_number)
row["captured_at"] = captured_at
row["answer"] = answer
row["digest"] = hashlib.sha256(answer.encode("utf-8")).hexdigest()[:12]
runs.append(row)
return sorted(runs, key=lambda row: (row["job"], row["captured_at"]))
def condition(row):
return tuple(row[field] for field in CONDITION_FIELDS)
def print_diff(previous, current):
lines = unified_diff(
previous["answer"].splitlines(),
current["answer"].splitlines(),
fromfile=previous["output_file"],
tofile=current["output_file"],
lineterm="",
)
print("\n".join(lines))
def main(path):
runs = read_runs(Path(path))
previous_by_job = {}
print("job,captured_at,model,mode,prompt_version,sha256,判定")
for current in runs:
previous = previous_by_job.get(current["job"])
if previous is None:
verdict = "初回"
elif previous["digest"] == current["digest"]:
verdict = "本文は同一"
elif condition(previous) == condition(current):
verdict = "同条件で差分あり"
else:
verdict = "条件変更あり・本文も差分あり"
print(
f"{current['job']},{current['captured_at'].isoformat()},"
f"{current['model']},{current['mode']},{current['prompt_version']},"
f"{current['digest']},{verdict}"
)
if previous is not None and previous["digest"] != current["digest"]:
print_diff(previous, current)
previous_by_job[current["job"]] = current
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("使い方: python chat_answer_audit.py answer_log.csv")
try:
main(sys.argv[1])
except (OSError, ValueError) as exc:
raise SystemExit(f"エラー: {exc}")
実行は次の1行です。
python chat_answer_audit.py answer_log.csv
実行して見た出力と、最初に間違えたこと
今朝、上の構成で用意した3件のテストログを実行しました。商談提案の2件目だけ、モデル・モード・プロンプトの版は同じで、本文に差分があります。
job,captured_at,model,mode,prompt_version,sha256,判定
商談提案,2026-08-06T16:20:00+09:00,GPT-5.6 Sol,quick,proposal-v3,12522103fc84,初回
商談提案,2026-08-07T09:15:00+09:00,GPT-5.6 Sol,quick,proposal-v3,c0e86e101e03,同条件で差分あり
--- answers/proposal_0806.txt
+++ answers/proposal_0807.txt
@@ -1,2 +1,2 @@
-導入候補はA社です。
+導入候補はA社とB社です。
定着支援の費用を確認します。
定例要約,2026-08-07T09:30:00+09:00,GPT-5.6 Sol,think,meeting-v1,f909ab0e8094,初回
ここで分かるのは「回答の質が落ちた」ではありません。候補社が1社から2社に増えたので、提案書へ貼る前に根拠を見直す必要がある、という一点です。diffが出たら失敗、ではない。確認すべき回答を絞れたら成功です。
最初は回答本文をCSVのセルに貼っていました。改行が多い回答を開いた途端、スプレッドシートの行高が崩れて、差分どころではなくなりました。本文を .txt に分けたら、CSVは条件の一覧として読めるようになりました。ここ、地味に効きます。
差分が出た時の見方を決めておく
同条件で差分あり は、回答が悪い印ではありません。ChatGPTの出力には揺れがあります。回答の比較対象として残すべきなのは、固有名詞、金額、日付、提案の結論、やる・やらないの判断です。語尾や見出しの順番まで毎回直し始めると、監査の方が本業を食います。
条件変更あり・本文も差分あり は、モデル、モード、プロンプト版のどれかを変えた記録です。この場合は回答差分だけでなく、条件の変更が意図したものだったかを見ます。たとえば提案の骨子を比較したい日は quick と think を混ぜず、同じプロンプト版で取り直します。
逆に、本文が同一でも安心し切れません。回答の裏付けにしたURLや社内資料の版は、このCSVでは追いません。そこまで必要な案件は、提案書側に出典欄を作ります。この小さな台帳に何でも背負わせない方が続きます。
再利用前の30秒を、説明できる30秒にする
ChatGPTの更新は便利です。だからこそ、営業や企画で使う回答は「いつ、どの条件で取ったか」を残したいです。
まずは今週使った下書きを1件だけ、本文と6列のCSVに分けて保存します。次に同じ仕事を頼んだ時、スクリプトを1回通す。本文が変わっていたら、貼り直す前に差分を見る。それで十分です。
再現性は大げさな検証環境から始まりません。提案書に入る一文を、あとで自分の言葉で説明できる状態にしておく。その積み重ねが、モデルの更新を仕事の改善に変えます。