見出し画像

妙高市の方言チャットボットVerUp:フェーズ1 完走!API キー移行と実験結果を確認する

フェーズ1 完走!API キー移行と実験結果を確認する

前回の記事では、辞書比較実験スクリプト(`experiment.py`)を完成させるところまで進めました。

今回は残っていた「旧辞書で実験を実行する」ステップを完了させます。その前に、実装の途中で API キー管理方式を変更したので、そのあたりも合わせてご紹介します。


API キー管理を `secrets.toml` に移行する

なぜ変更するのか

前回の記事で `.env` ファイルで API キーを管理していた話をしました。実装を進めているとき、X で「`.env` はセキュリティリスクが大きいので使うな」といった記事を見つけました。調べてみると確かにリスクがあり、使わないことが推奨されているようです。そこで、Streamlit の `secrets.toml` を使った管理方式に移行することにしました。

Streamlit には公式の秘密情報管理の仕組みがあったのです。`.env` と `python-dotenv` の組み合わせより、こちらを使う方が自然です。

変更内容

$$
\begin{array}{|l|l|}
\hline
\text{ファイル} & \text{変更内容} \\
\hline
\texttt{.streamlit/secrets.toml} & \text{API Key を書く実ファイル(.gitignore 対象)} \\
\texttt{.streamlit/secrets.toml.example} & \text{テンプレートファイル(リポジトリにコミット)} \\
\texttt{.env.example} & \text{削除(不要になったため)} \\
\texttt{requirements.txt} & \texttt{python-dotenv} \rightarrow \texttt{tomli} \text{ に変更} \\
\texttt{experiment/experiment.py} & \text{API キーの読み込み方法を変更} \\
\hline
\end{array}
$$

`app.py` はもともと `st.secrets["OPENAI_API_KEY"]` を使っていたので変更不要でした。ローカルでは `.env` を使っていましたが、GitHub から Streamlit Cloud に連携させる段階で `st.secrets` を使う実装に切り替えていました。ですので、アプリ本体としては最初から正しい実装だったわけです。

`experiment.py` の変更点

実験スクリプトは `load_dotenv()` で `.env` を読んでいたので、`tomli` で `secrets.toml` を読むように書き直しました。

# 変更前
from dotenv import load_dotenv
load_dotenv()
api_key = os.environ.get("OPENAI_API_KEY")

# 変更後
import tomli as tomllib
with open(BASE_DIR / ".streamlit" / "secrets.toml", "rb") as f:
    _secrets = tomllib.load(f)
api_key = _secrets.get("OPENAI_API_KEY")

ちょっとしたはまりどころ: `tomllib` は Python 3.11 から標準ライブラリに含まれていますが、今回の環境は Python 3.10 です。そのため `tomli` という互換パッケージを使っています(`import tomllib` ではなく `import tomli as tomllib` とエイリアスを付けて使うのが定石です)。


フェーズ1 実験を実行する

API キー管理の整備が終わったので、いよいよ実験スクリプトを動かします。

.venv/bin/python experiment/experiment.py

`dialect_dict_old.txt`(旧辞書バックアップ)がない状態で実行するとフェーズ1 モードになります。現在の辞書(`dialect_dict.txt`)をそのまま「旧辞書」として翻訳・記録します。


実験結果

10 フレーズすべての翻訳が成功しました。

  1. こんにちは。今日はいい天気ですね。
    こんにちは。今日はええ天気だねや。

  2. そうですね。だいぶ暖かくなってきました。
    そうだねや。だいぶあったかくなってきたわね。

  3. 体調はどうですか?しばらく顔を見ませんでしたが。
    なじょだね?しばらく顔めーねかったでも。

  4. 気温が上がったり下がったりしたせいか、風邪をひいて寝込んでいました。だいぶ元気になりました。
    気温が上がったり下がったりしたすけか、風邪ひいて寝込んでたんだわね。ばか元気になったわ。

  5. そうですか。それは大変でしたね。
    そうだかね。それはたいそだったねや。

  6. 元気になったんで山菜を取りにでも行こうかと思っています。
    げんきになったすけ山菜取りにでも行こかと思ってるんだわね。

  7. いいですね。山菜といえば、先日温泉宿で美味しい山菜料理をいただきました。ごちそうでした。
    ええねや。山菜といえば、こねだ温泉宿でうんめえ山菜料理くんないました。ごっつぉだったわ。

  8. それは良かったですね。温泉はしばらく行ってないので、私も行ってみたいです。
    それはえかったねや。温泉はしばらく行ってねぇすけ、おらも行ってみてえわ。

  9. 温泉に入って、ゆったりするのが大好きなんですよ。
    温泉入って、じょんのびするの大好きなんだわね。

  10. 温かいお湯に浸かって、美味しいごちそうを食べるのは天国みたいですね。
    あったけえよに浸かって、うんめえごっつぉ食うのは天国みてえだねや。

翻訳を読んでみる

方言らしい表現がしっかり出ています。

  • 「なじょだね?」(3番)── 「どうですか?」の方言形。辞書に収録されている核心的な表現のひとつです。

  • 「すけ」(4・6番)── 「〜なので」という意味の接続詞。標準語の「〜だから」に対応します。

  • 「ばか」(4番)── 「とても」という意味の強調語。「ばか元気」という使い方。

  • 「こねだ」(7番)── 「先日」の方言形。

  • 「じょんのび」(9番)── ゆったりくつろぐさまを表す新潟の方言。

  • 「ごっつぉ」(7・10番)── 「ごちそう」の方言形。

方言らしさはしっかり出ているものの、一部の表現には少し違和感を覚える箇所もあります。例えば、「山菜料理くんないました」とは言いません。辞書を書き直したフェーズ2 の結果と比べて、その違和感が改善されるかどうかも確認したいところです。


トークン数に注目する

実験スクリプトはトークン数も記録しています。

$$
\begin{array}{|l|l|l|l|l|}
\hline
\ フレーズ & 入力Token & キャッシュToken & キャッシュ率 & 出力Token \\
\hline
\ 1フレーズ目 & 3,539 & 0 & 0\% & 15 \\
\ 2フレーズ目以降 & 3,545\sim3,566 & 3,328 & 93\sim 94\% & 16 \sim 42 \\
\ 合計(10フレーズ) & 35,516 & 29,952 & 84.3\% & 280 \\
\hline
\end{array}
$$

注目ポイント:2フレーズ目からキャッシュが効いています。

システムプロンプト(辞書を含む翻訳指示)は約 3,328 トークンあります。フレーズごとに独立したリクエストを送っているにもかかわらず、同じセッション内では 2 回目以降でキャッシュにヒットしています。

入力トークン数が全フレーズでほぼ同じ(3,539〜3,566)なのも、入力の大部分が辞書(約 3,300 トークン)だからです。フレーズ本文はわずか数十トークンなので、全体のトークン数に大きな差が出ません。

キャッシュが効くことで、同一セッション内では 2 回目以降のリクエストコストが下がります。フェーズ2 で辞書が変わったとき、トークン数にどう影響が出るかも比較ポイントのひとつです。


フェーズ1 完了

フェーズ1 のすべてのステップが終わりました。

✅ ブランチ作成
✅ `.gitignore` 更新
✅ `requirements.txt` 更新
✅ 仮想環境構築・パッケージインストール
✅ API キー管理を `secrets.toml` に移行
✅ テスト用フレーズ作成
✅ 実験スクリプト作成
✅ 旧辞書で実験を実行・結果を記録

次回はフェーズ2 に進みます。辞書(`dialect_dict.txt`)を新しい書式に書き直し、同じスクリプトで再実行します。旧辞書と新辞書の翻訳結果・トークン数を並べて比較できるようになります。


参考:実験スクリプト全文

今回の実験で使用した `experiment/experiment.py` の全文を掲載します。chatGPTなどにコピペして解説してもらうと、勉強になると思います。Google Colab で主要部分を動かしてみるのもお勧めです。

import sys
import tomli as tomllib
from pathlib import Path
from openai import OpenAI

BASE_DIR = Path(__file__).parent.parent
_secrets_path = BASE_DIR / ".streamlit" / "secrets.toml"
if not _secrets_path.exists():
    print(f"エラー: {_secrets_path} が見つかりません。")
    sys.exit(1)
with open(_secrets_path, "rb") as _f:
    _secrets = tomllib.load(_f)

PHRASES_FILE = BASE_DIR / "plans" / "experiment_phrases.txt"
OLD_DICT_FILE = BASE_DIR / "dialect_dict_old.txt"
NEW_DICT_FILE = BASE_DIR / "dialect_dict.txt"
RESULT_FILE = BASE_DIR / "plans" / "experiment_result.md"

MODEL = "gpt-5.4-mini"

TRANSLATION_PROMPT_TEMPLATE = """\
あなたは、標準語を妙高市の方言に翻訳するシステムです。
入力された標準語テキストを妙高市の方言に翻訳してください。
翻訳結果のみを出力し、説明は不要です。

### 辞書 ###
{dict_content}"""


def load_phrases():
    phrases = []
    with open(PHRASES_FILE, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line.startswith("- "):
                phrases.append(line[2:])
            elif line:
                phrases.append(line)
    return phrases


def load_dict(path):
    with open(path, encoding="utf-8") as f:
        return f.read()


def run_experiment(client, phrases, dict_content, label):
    system_prompt = TRANSLATION_PROMPT_TEMPLATE.format(dict_content=dict_content)
    results = []
    for i, phrase in enumerate(phrases, 1):
        response = client.responses.create(
            model=MODEL,
            instructions=system_prompt,
            input=phrase,
            max_output_tokens=200,
        )
        result = {
            "phrase": phrase,
            "translation": response.output_text.strip(),
            "input_tokens": response.usage.input_tokens,
            "output_tokens": response.usage.output_tokens,
            "total_tokens": response.usage.total_tokens,
            "cached_tokens": response.usage.input_tokens_details.cached_tokens,
        }
        results.append(result)
        print(
            f"[{label}] {i}/{len(phrases)} "
            f"入力:{result['input_tokens']}(キャッシュ:{result['cached_tokens']}) "
            f"出力:{result['output_tokens']}"
        )
        print(f"  {phrase}")
        print(f"  → {result['translation']}")
    return results


def format_table(label, results):
    lines = [f"## {label}", ""]
    lines.append("| # | 標準語 | 翻訳結果 | 入力Token | キャッシュToken | キャッシュ率 | 出力Token | 合計Token |")
    lines.append("|---|--------|----------|-----------|----------------|------------|-----------|-----------|")
    total_input = total_output = total = total_cached = 0
    for i, r in enumerate(results, 1):
        cache_rate = r["cached_tokens"] / r["input_tokens"] * 100 if r["input_tokens"] else 0
        lines.append(
            f"| {i} | {r['phrase']} | {r['translation']} "
            f"| {r['input_tokens']} | {r['cached_tokens']} | {cache_rate:.1f}% "
            f"| {r['output_tokens']} | {r['total_tokens']} |"
        )
        total_input += r["input_tokens"]
        total_output += r["output_tokens"]
        total += r["total_tokens"]
        total_cached += r["cached_tokens"]
    overall_rate = total_cached / total_input * 100 if total_input else 0
    lines.append(
        f"| **合計** | | | {total_input:,} | {total_cached:,} | {overall_rate:.1f}% | {total_output:,} | {total:,} |"
    )
    lines.append("")
    return "\n".join(lines), total_input, total_output, total, total_cached


def main():
    api_key = _secrets.get("OPENAI_API_KEY")
    if not api_key:
        print("エラー: OPENAI_API_KEY が設定されていません。.streamlit/secrets.toml を確認してください。")
        sys.exit(1)

    client = OpenAI(api_key=api_key)
    phrases = load_phrases()
    print(f"フレーズ数: {len(phrases)}\n")

    has_old_backup = OLD_DICT_FILE.exists()
    has_current = NEW_DICT_FILE.exists()

    if not has_current:
        print(f"エラー: 辞書ファイルが見つかりません: {NEW_DICT_FILE}")
        sys.exit(1)

    sections = []

    if has_old_backup:
        # フェーズ2: 旧辞書(バックアップ)と新辞書を比較
        print("=== 旧辞書で実験中 ===")
        old_results = run_experiment(client, phrases, load_dict(OLD_DICT_FILE), "旧辞書")
        sections.append(("旧辞書", *format_table("旧辞書", old_results)))

        print("\n=== 新辞書で実験中 ===")
        new_results = run_experiment(client, phrases, load_dict(NEW_DICT_FILE), "新辞書")
        sections.append(("新辞書", *format_table("新辞書", new_results)))
    else:
        # フェーズ1: 現在の辞書のみ(旧辞書として記録)
        print("=== 旧辞書で実験中 ===")
        old_results = run_experiment(client, phrases, load_dict(NEW_DICT_FILE), "旧辞書")
        sections.append(("旧辞書", *format_table("旧辞書", old_results)))

    # 結果ファイルの組み立て
    output = "# 実験結果\n\n"
    for name, section, *_ in sections:
        output += section + "\n"

    if len(sections) == 2:
        _, _, old_in, old_out, old_total, old_cached = sections[0]
        _, _, new_in, new_out, new_total, new_cached = sections[1]
        old_rate = old_cached / old_in * 100 if old_in else 0
        new_rate = new_cached / new_in * 100 if new_in else 0
        output += "## 比較サマリー\n\n"
        output += "| 指標 | 旧辞書 | 新辞書 | 差分 |\n"
        output += "|------|--------|--------|------|\n"
        output += f"| 合計入力Token | {old_in:,} | {new_in:,} | {new_in - old_in:+,} |\n"
        output += f"| キャッシュToken合計 | {old_cached:,} | {new_cached:,} | {new_cached - old_cached:+,} |\n"
        output += f"| キャッシュ率 | {old_rate:.1f}% | {new_rate:.1f}% | — |\n"
        output += f"| 合計出力Token | {old_out:,} | {new_out:,} | {new_out - old_out:+,} |\n"
        output += f"| 合計Token | {old_total:,} | {new_total:,} | {new_total - old_total:+,} |\n"

    RESULT_FILE.write_text(output, encoding="utf-8")
    print(f"\n結果を保存しました: {RESULT_FILE}")


if __name__ == "__main__":
    main()

開発中ブランチへのリンクです。

続く



書いている人について

最後まで読んでいただき、ありがとうございます。
私のnoteの紹介記事です。
よろしければ、お読みいただければと思います。

この記事の連載マガジンです。

私の生まれ故郷の歴史

最後に
この記事が気に入ったら、スキをいただけると励みになります。

いいなと思ったら応援しよう!