[Claude v3] GASで動く社内偏愛マップ表示アプリを作った

## 完成したもの

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/da13712a-e51e-8f78-3369-bc7bc57fea73.png)

## 偏愛マップ
社内でランチミーティングを開催しているのだが、だんだんと話す内容がなくなってきたので、話のネタとしてメンバーの偏愛マップを作って、それを見ながら話をしようということになった。
メンバーの偏愛マップとして、Google Spreadsheetに以下のようなフォーマットで記載するようにした。

![スクリーンショット 2024-04-08 13.15.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/d5b936bd-4fe6-4b31-b175-6d352052704d.png)

ただ、この状態では、偏愛マップの共通項がわかりにくいというのと、Spreadsheetでは視認性が良くないので、Webアプリを作ったほうが良さそう。
GASであれば公開範囲をドメインに制限することで社内ドメインしかアクセスできなくなるので、セキュリティの考慮もいらず、サーバを用意する必要もなく、APIで手軽にSpreadsheetのデータを扱えると考えGASでWebアプリを作ってみることに。
さらにGASからOpenAI APIでデータ分析させて偏愛マップ同士の関連性とか出してみると良さそう。

## GASでReactを使って画面開発したい

いくつかテンプレートがあったが、古かったりして好みではなかったので自前でやることにした。
色々調べると `vite-plugin-singlefile` を入れればなんとかなりそうだったので、そのへんを中心に入れて、以下のようにした
```
  "dependencies": {
    "@emotion/react": "^11.11.4",
    "@emotion/styled": "^11.11.5",
    "@mui/lab": "5.0.0-alpha.170",
    "@mui/material": "^5.15.15",
    "gas-client": "^1.1.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
  },
  "devDependencies": {
    "@types/google-apps-script": "^1.0.82",
    "@types/react": "^18.2.66",
    "@types/react-dom": "^18.2.22",
    "@typescript-eslint/eslint-plugin": "^7.2.0",
    "@typescript-eslint/parser": "^7.2.0",
    "@vitejs/plugin-react": "^4.2.1",
    "esbuild": "^0.20.2",
    "esbuild-gas-plugin": "^0.8.0",
    "eslint": "^8.57.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.6",
    "typescript": "^5.2.2",
    "vite": "^5.2.0",
    "vite-plugin-singlefile": "^2.0.1",
    "vite-tsconfig-paths": "^4.3.2"
  }
```
`vite.config.ts` は以下のように記載
```
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { viteSingleFile } from 'vite-plugin-singlefile';
import tsconfigPaths from "vite-tsconfig-paths";

const frontendBuild = defineConfig({
  plugins: [react(), tsconfigPaths(), viteSingleFile()],
  build: {
    outDir: "dist",
    minify: true,
  }
});

export default frontendBuild;
```
`build.cjs` は以下のように記載
```
const { GasPlugin } = require('esbuild-gas-plugin');

require('esbuild').build({
  entryPoints: ['src/server/index.ts'],
  bundle: true,
  outfile: 'dist_server/server.js',
  plugins: [GasPlugin],
  minify: true,
}).catch((e) => {
  console.error(e)
  process.exit(1)
})
```
このようにすることで、 `package.json` に以下のように定義し
```
  "scripts": {
    "build:frontend": "vite build",
    "build:server": "node build.cjs",
    "build": "pnpm run build:frontend && pnpm run build:server",
    "push": "rm -rf ./dist/* && pnpm build && cp ./dist_server/server.js ./dist && rm -rf ./dist_server/ && cp ./appsscript.json ./dist && clasp push"
```
`pnpm build` でフロント側、サーバサイド側(GASではサーバサイド側でしかAPIを実行できないため分ける)をビルドできるようになります
また、`pnpm push`ではビルドを行いつつ、必要なファイルを含めて`clasp`でpushできるようになります


## 処理の流れ
![qiita_outline.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/6b2d53bc-6509-aea3-c91e-940a68e233da.png)

1. スプレッドシートのシート名がメンバーの名前になっているので、メンバー名を取得して画面表示
1. メンバー名の一覧から、ランチミーティングに参加するメンバーの名前を選択
1. 選択されたメンバーの偏愛マップをスプレッドシートから取得
1. 偏愛マップのデータをOpenAI APIでカテゴライズ

という感じです。
カテゴライズしたデータは、プログラムで扱いやすいようにJSONフォーマットで受け取りたいので、OpenAI APIの `https://api.openai.com/v1/chat/completions` のリクエスト時に、`response_format: { "type": "json_object" }` を指定してあげればJSON形式で返してくれます。

https://platform.openai.com/docs/api-reference/chat/create

## 問題発生
![gc_log_outline.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/f867db79-bddd-1a01-2c93-f1bef6c9eb0f.png)

**OpenAI APIめっちゃ応答遅い!!**  
GPT-4 TurboでもGPT-3 Turboでも応答が遅い!!
リクエストから2分ほど経過して、空っぽの応答が返されるという事象が頻発。
どうしたもんか・・・。

## OpenAI APIからAWS Bedrock Claude v3へ乗り換え
Pythonで試した感じ、AWS BedrockのClaude v3のほうが安定してレスポンスを返してくれた。
`anthropic.claude-3-haiku-20240307-v1:0`は結構速い。
`anthropic.claude-3-sonnet-20240229-v1:0`でも耐えれるくらいの速度だった。
sonnetでも10秒かからないくらいなので、sonnetを使用することにした。

## Claude v3でJSONのレスポンスを作る

Claude v3には、JSONの応答を強制するようなパラメータがなかった

https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages.html

が、**System prompts** で指示すれば、ある程度できそう

https://docs.anthropic.com/claude/docs/system-prompts

Bedrockリクエスト時に `"system": "Respond only JSON"` と指定したところ、JSONフォーマットで返してくれるようになった。
と思ったら、たまに余計な文字がついている時があり、`JSON.parse`に失敗することがある。
なので、応答のテキストから、JSON部分を抜き出して`JSON.parse`する処理を作った。
```
const parseFirstJsonObject = (text: string): object => {
  // 最初の '{' のインデックスを見つける
  const startIndex = text.indexOf('{');
  if (startIndex === -1) return {}; // '{' が見つからない場合は空を返す

  // 対応する '}' のインデックスを見つけるためのカウンター
  let openBraces = 1;
  let endIndex = startIndex + 1;

  while (endIndex < text.length && openBraces > 0) {
    if (text[endIndex] === '{') {
      openBraces++;
    } else if (text[endIndex] === '}') {
      openBraces--;
    }
    if (openBraces > 0) {
      endIndex++;
    }
  }

  if (openBraces !== 0) return {}; // 対応する '}' が見つからない場合は空を返す

  // JSON 文字列を抽出して解析する
  try {
    const jsonString = text.substring(startIndex, endIndex + 1);
    return JSON.parse(jsonString);
  } catch (e) {
    console.error('JSONの解析に失敗しました: ', e);
    return {};
  }
}
```

## 工夫ポイント
* カテゴリ名に絵文字をつけてもらうようプロンプトで指示
* 偏愛マップはランダムで表示したいけど、他の人が開いても同じ並びで表示したい
  `seedrandom` を使って、選択した名前を結合した文字列で乱数を作り、ランダムに並び替えした
* 多くのCardが並ぶので、`n番についてなんですけど〜`みたいに話せると、どの話題か特定するのが楽そうだと思いCardに番号を表示できるようにした
  CardのCardHeaderにAvatarを入れ、Avatarのテキストに番号を入れることで強引に解決
  ```
  <CardHeader avatar={<Avatar sx={{ bgcolor: "#333333" }}>{data.index}</Avatar>} title={`${data.name}: ${data.title}`} titleTypographyProps={{ fontSize: "1.1rem" }} />
  ```
* 人毎に色を変えて偏愛マップを表示
  `chroma-js` で以下のようにしてパステル系の色をメンバー数分動的に作るようにした
  ```
  const colors = chroma.scale(['#f0e0d0', '#f0e0ff', '#d0f0e0']).mode('lch').colors(names.length)
  ```
* 他の人が開いても同じカテゴリ情報で表示したい
  取得したカテゴリ情報を、スクリプトプロパティに保存
  シートの最終更新日時、選択されたメンバーが同じであればキャッシュから取得できるようにした  
  キャッシュを使用することで**APIの課金も節約!**
  ただし、強制的にカテゴリを再分析するためのボタンを置いて、キャッシュを無視できるようにした

今後メンバーに使ってもらいながらブラッシュアップしていきます

## 参考

https://technexus-blog.mbs.jp/2023/10/gasgoogle-app-scriptaws-bedrock.html

https://docs.anthropic.com/claude/docs/system-prompts

https://docs.anthropic.com/claude/docs/control-output-format

https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages.html