ラン画像から抽出したテキストをChatGPTのプロンプトで解析してみた

# 前書き
この記事は連作で、ラン記録をシェアし、メンバーの走行記録を集計する活動を、LINE Botで自動化しようとしている取り組みです。

Cloud Vision APIでテキスト検出できることから、目で読んで手で打つという手間を省けないかと取り組み始めました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3381/fd34ff60-85e2-2d38-841d-9ced6f041fde.png)

クリティカルな課題はそのまま未解決ながら、日々ラン画像が投稿され、集計に活躍しています。

- [定期的にGASのアクセス承認が要求されWebhookが止まってしまう](https://qiita.com/quwaji/items/b07fc7f0821630bf7505) →未解決
- [LINEのUserIDに対して名前を設定する仕組みがなく、メンバーが増えた際に管理人が手で設定している。](https://qiita.com/quwaji/items/ff70ae9f0f018eae278d) → うまく動作していない

タイムや距離がうまく読み取られなかった場合も「修正」に誘導する機能を作り、利用者が自らLINEのチャット内で修正してくれており、ときどきパターンマッチングの処理を追加修正して、新たに検出されたエラーに対応していました。

→[プルリクのほとんどがパターンマッチ修正](https://github.com/run-dreams/linebot-gas/pulls?q=is%3Apr+is%3Aclosed)

# ChatGPTのプロンプトで解析
2023年に入ってChatGPTのAPIも公開され、さまざまな組み込みも行われています。そして最近バズった記事で、なるほどChatGPTで解析できそうだなと今更ながら気づきました。

https://qiita.com/watanabe-tsubasa/items/12dc7ba9a6de55e8afd9

あるデータを元に、プロンプトを与えてテキストを生成する流れは、前職でも見ていたので、GASで試しに組んでみようというのが今回の記事になります。

# GASでの組み込み

以下の記事を参考に、呼び出し部分は作成しました。

https://qiita.com/Tyamamoto1007/items/e144119a4bc737b9f097

openaiのapikey取得は、以前に参加したオンラインのWorkshopで作成したことがあったので、ログインしてキーを追加しました。

https://mdqu.rcis.jp/course/view.php?id=26#section-4

GASのプロジェクトにもプロパティを追加して、これを読むようにします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3381/6db053b1-b08d-bac5-f1f9-9215013e4f22.png)

```js:chatgpt.gs
var OPENAI_API_KEY = PropertiesService.getScriptProperties().getProperty("OPENAI_API_KEY");
```

プロンプトの文字列を定義
```js:chatgpt.gs
var prompt = `以下は、画像からGoogle Cloud Visionで抽出されたテキストで、スマホアプリのスクリーンショットやランニングウォッチの表示をスマホで撮影したものになります。
このテキストの中から、走った距離と時間を抽出してください。
json形式で、distanceとdurationという値を返してください。
距離は数値を文字列で返してください。kmは不要です。
距離がマイルで検出された場合はkmに換算してください。
タイムは、日本語であっても０：００：００形式にしてください。分秒で時がない場合もです。
スマホのスクリーンショットはOSの時計表示が先頭に入ることもあり、注意が必要です。
距離やタイムに関連するラベルが近くに配置されていることも目印になります。
タイムの時分秒のうち、時が上付数字（¹）で認識される分とつながって見えるケースがあります。
`;
```
これまでせっせと、正規表現とGoogle Apps Scriptで書いていた内容を日本語の指示で書いている感じですね。

そして、API呼び出し部分です。
```js:chatgpt.gs
// ChatGPTにプロンプトとOCRされたテキストを与えて距離とタイムを取得
function detectTimeAndDistanceByGPT(text) {
  //ChatGPTのAPIのエンドポイントを設定
  const apiUrl = 'https://api.openai.com/v1/chat/completions';
  //ChatGPTに投げるメッセージを定義(ユーザーロールの投稿文のみ)
  const messages = [{'role': 'user', 'content': prompt + text}];
  //OpenAIのAPIリクエストに必要なヘッダー情報を設定
  const headers = {
    'Authorization':'Bearer '+ OPENAI_API_KEY,
    'Content-type': 'application/json',
    'X-Slack-No-Retry': 1
  };
  //ChatGPTモデルやトークン上限、プロンプトをオプションに設定
  const options = {
    'muteHttpExceptions' : true,
    'headers': headers, 
    'method': 'POST',
    'payload': JSON.stringify({
      //'model': 'gpt-3.5-turbo',
      'model': 'gpt-4',
      'max_tokens' : 1024,
      'temperature' : 0.9,
      'messages': messages})
  };
  //OpenAIのChatGPTにAPIリクエストを送り、結果を変数に格納
  const response = JSON.parse(UrlFetchApp.fetch(apiUrl, options).getContentText());
  return response.choices[0].message.content;
}

```

これでChatGPTのAPIからは、以下のようなjsonが得られます。

```json:API Result
{ "distance": "5.04", "duration": "00:24:39" } 
```
>   return response.choices[0].message.content;

チャットの最初の応答の中身ですね。

# いざ、検証！
これまで数々のパターンマッチ修正をするたびに、テキストと期待する距離・タイムの組み合わせをテストとして残していました。数にして４２パターン。

```js:chatgpt.gs
function detectTestGpt(name, result, duration, distance) {
  analized = detectTimeAndDistanceByGPT(result);
  try{
    td = JSON.parse(analized);
    dt = td.duration;
    dd = td.distance;
    console.log(name + ': ' + dd + ', ' + dt + ' result: ' + (dd == distance && dt == duration ? 'OK' : 'NG'));
    return (dd == distance && dt == duration ? 'o' : 'x');
  }
  catch{
    console.log(name + ': ' + analized + ' result: NG');
    return ('x');
  }
}
```

ChatGPTが以下のような応答をすることがあるため、JSONがどうかにより処理を分岐しています。

> このテキストからは、走った距離と時間が明確に抽出できません。テキストには複数の距離が記載されていますが、それらがどのランニングに対応するのか、また時間についても「23:47」や「18:34」など複数の時間が記載されていますが、これが走行時間を示しているのかは不明です。より具体的な情報が必要です。

この場合もjsonで返すようにプロンプトで指示してもよいですね。

## ChatGPT APIのモデルとパラメータ

modelとtemptureという二つのパラメータについて、組み合わせで検証してみました。42パターンのうち失敗した数になります。

| model/ tempture | 0.0 | 0.9 |
|:-:|:-:|:-:|
| gpt-3.5-turbo  |  24 | 26  |
| gpt-4-0619  | 22  | 27  |

> temperature (オプション): モデルの応答の多様性を制御します。値が低いほど、より決定論的な応答が生成されます（0.0に近づくほど決定的になります）、高いほどランダムな応答が生成されます（1.0に近づくほどランダムになります）。[※参照元](https://mdqu.rcis.jp/course/view.php?id=26#section-4)

失敗しているパターンは、Vision AIの読み取り段階で数字が逆さまに認識されているケースや時分秒の時が上付文字で認識されているケースなどで、プロンプトでどうにかするより入力ソースをもっと整えた方がいいような気もしました。

https://github.com/run-dreams/linebot-gas/issues/11

https://github.com/run-dreams/linebot-gas/issues/13

他にも、既存のパターンマッチの返す検出された時間のフォーマットが揃っていなくて、テストコードの比較処理でエラーになっているものもあり、プロンプトで検出できたものはもう少し多いです。
今回はAPI呼び出しをGASでやってみることと、使えそうかどうかを見てみるというのが目的でしたので、これ以上検証コードの調整は行いません。

## gpt-4では連続呼び出しに引っかかった
既存のテストパターンを全て実行する[テストコード](https://github.com/run-dreams/linebot-gas/commit/373a26775bf08499f2795d79b0720f4d76a45188#diff-4aa8b4358c45906cb6646a809167cbeb35f5c5c3083737a59f6e531b52901ccdR57-R103)を走らせたところ、途中（29件目とか33件目）でエラーがでました。

```json
{ error:
{ message: 'Rate limit reached for gpt-4 in organization org-fh5PfF4U7Nih5NuphBQZoJZ3 on tokens per min (TPM): Limit 10000, Used 8934, Requested 1316. Please try again in 1.5s. Visit https://platform.openai.com/account/rate-limits to learn more.',
type: 'tokens',
param: null,
code: 'rate_limit_exceeded' } }
```

エラーメッセージをみると、連続呼び出しによるものでした。

# まとめ

GASからChatGPTのAPIを呼び出して、プロンプトを与えて解析処理をすることができました。

また、テキスト生成の用途ではGASで組んでいるアプリでもさまざま使えそうです。

GeminiやClaude 3など、新たな生成AIで直接画像を読ませて解析してもらうこともできそうです。

本プロジェクトとしては、パターンマッチが失敗した時に走らせてみて、結果が取れれば採用してそのことを開発チームがわかるようにすると、ひょっとしたらバックアップになるかもしれないなと思いました。

また時間のあるときに、テストデータの定義を見直してテストコードを共通化し、検出に失敗した時の処理を加えてみたいと思います。