Skip to content
GitHub Twitter

Evals を Cucumber で書く

前回の記事で Evals の概念を書いた。今回は実装の話。

OpenAI の記事を読んで「で、どうやって書くの?」となった人向け。

なぜ Cucumber か

Evals のテストケースは普通 CSV で書く:

id,should_trigger,prompt
test-01,true,"一覧を表示して"
test-02,false,"README を要約して"

問題: should_trigger が何を意味するかコンテキストがないと分からない。

Cucumber だとこうなる:

シナリオ: 一覧表示のリクエスト
  前提 プロンプト:
    """
    一覧を表示して
    """
  もし Codex でプロンプトを実行する
  ならば list コマンドが実行されている

シナリオ: README 要約では list を実行しない
  前提 プロンプト:
    """
    README を要約して
    """
  もし Codex でプロンプトを実行する
  ならば list コマンドが実行されていない

何をテストしてるか一目で分かる。

日本語で書ける

Cucumber は多言語対応してる。ファイル先頭に # language: ja を書くだけ:

# language: ja

機能: doc-fetcher evals
  doc-fetcher の list 実行が必要なプロンプトでは実行し、
  不要なプロンプトでは実行しないことを検証する。

  シナリオ: 追跡中 URL の一覧を表示する
    前提 プロンプト:
      """
      doc-fetcher で追跡中の URL 一覧を表示して
      """
    もし Codex でプロンプトを実行する
    ならば list コマンドが実行されている

Given/When/Then が「前提/もし/ならば」になる。

Step Definitions

裏側の実装。Ruby で書く。

Given(/^プロンプト:$/) do |doc_string|
  @prompt = doc_string.to_s.strip
end

When('Codex でプロンプトを実行する') do
  # Codex CLI を --json モードで実行
  stdout, stderr, status = Open3.capture3(
    'codex', 'exec', '--json', '--full-auto', @prompt
  )
  
  # 実行ログを保存(後で見返せる)
  @run_path = "evals/runs/#{@eval_id}.jsonl"
  File.write(@run_path, stdout)
  
  @codex_status = status
end

ポイントは codex exec --json。これで実行トレースが JSONL で取れる。

コマンド実行の検出

JSONL から command_execution イベントを抽出する:

def extract_commands(run_path)
  commands = []
  File.foreach(run_path) do |line|
    event = JSON.parse(line)
    item = event['item']
    
    next unless item['type'] == 'command_execution'
    next unless item['status'] == 'completed'
    
    commands << item['command']
  end
  commands
end

あとは正規表現でチェック:

LIST_CMD_RE = %r{doc_fetcher\.rb\s+list}i

Then('list コマンドが実行されている') do
  commands = extract_commands(@run_path)
  list_cmds = commands.select { |cmd| cmd.match?(LIST_CMD_RE) }
  
  raise 'list command not found' if list_cmds.empty?
end

Then('list コマンドが実行されていない') do
  commands = extract_commands(@run_path)
  list_cmds = commands.select { |cmd| cmd.match?(LIST_CMD_RE) }
  
  raise 'unexpected list command' unless list_cmds.empty?
end

実行

bundle exec cucumber features/doc_fetcher_evals.feature

失敗したシナリオがあれば、evals/runs/ に JSONL が残ってるから何が起きたか追える。

positive と negative

両方書く。これ大事。

positive: このプロンプトでは list を実行すべき

シナリオ: 追跡中 URL の一覧を表示する
  ...
  ならば list コマンドが実行されている

negative: このプロンプトでは list を実行すべきでない

シナリオ: README 要約では list を実行しない
  ...
  ならば list コマンドが実行されていない

negative がないと、何でも list 実行するようになっても気づけない。

まとめ

  • CSV より Cucumber の方が読みやすい
  • # language: ja で日本語で書ける
  • codex exec --json で実行トレースを取る
  • JSONL から command_execution を抽出してチェック
  • positive と negative 両方書く

概念は OpenAI の記事で、実装はこのパターンで。


参考: