0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ChatGPTの回答差分を残すPython監査術

0
Posted at

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

modelmode は、回答を取った画面で見えた表記をそのまま入れます。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の出力には揺れがあります。回答の比較対象として残すべきなのは、固有名詞、金額、日付、提案の結論、やる・やらないの判断です。語尾や見出しの順番まで毎回直し始めると、監査の方が本業を食います。

条件変更あり・本文も差分あり は、モデル、モード、プロンプト版のどれかを変えた記録です。この場合は回答差分だけでなく、条件の変更が意図したものだったかを見ます。たとえば提案の骨子を比較したい日は quickthink を混ぜず、同じプロンプト版で取り直します。

逆に、本文が同一でも安心し切れません。回答の裏付けにしたURLや社内資料の版は、このCSVでは追いません。そこまで必要な案件は、提案書側に出典欄を作ります。この小さな台帳に何でも背負わせない方が続きます。

再利用前の30秒を、説明できる30秒にする

ChatGPTの更新は便利です。だからこそ、営業や企画で使う回答は「いつ、どの条件で取ったか」を残したいです。

まずは今週使った下書きを1件だけ、本文と6列のCSVに分けて保存します。次に同じ仕事を頼んだ時、スクリプトを1回通す。本文が変わっていたら、貼り直す前に差分を見る。それで十分です。

再現性は大げさな検証環境から始まりません。提案書に入る一文を、あとで自分の言葉で説明できる状態にしておく。その積み重ねが、モデルの更新を仕事の改善に変えます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?