妙高市の方言チャットボット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 フレーズすべての翻訳が成功しました。
こんにちは。今日はいい天気ですね。
こんにちは。今日はええ天気だねや。そうですね。だいぶ暖かくなってきました。
そうだねや。だいぶあったかくなってきたわね。体調はどうですか?しばらく顔を見ませんでしたが。
なじょだね?しばらく顔めーねかったでも。気温が上がったり下がったりしたせいか、風邪をひいて寝込んでいました。だいぶ元気になりました。
気温が上がったり下がったりしたすけか、風邪ひいて寝込んでたんだわね。ばか元気になったわ。そうですか。それは大変でしたね。
そうだかね。それはたいそだったねや。元気になったんで山菜を取りにでも行こうかと思っています。
げんきになったすけ山菜取りにでも行こかと思ってるんだわね。いいですね。山菜といえば、先日温泉宿で美味しい山菜料理をいただきました。ごちそうでした。
ええねや。山菜といえば、こねだ温泉宿でうんめえ山菜料理くんないました。ごっつぉだったわ。それは良かったですね。温泉はしばらく行ってないので、私も行ってみたいです。
それはえかったねや。温泉はしばらく行ってねぇすけ、おらも行ってみてえわ。温泉に入って、ゆったりするのが大好きなんですよ。
温泉入って、じょんのびするの大好きなんだわね。温かいお湯に浸かって、美味しいごちそうを食べるのは天国みたいですね。
あったけえよに浸かって、うんめえごっつぉ食うのは天国みてえだねや。
翻訳を読んでみる
方言らしい表現がしっかり出ています。
「なじょだね?」(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の紹介記事です。
よろしければ、お読みいただければと思います。
この記事の連載マガジンです。
私の生まれ故郷の歴史
最後に
この記事が気に入ったら、スキをいただけると励みになります。
