🤖

【実験編】ChatGPTでCSS in JSの置き換えを自動化する

2023/11/30に公開

この記事はYAMAPアドベントカレンダー1日目の記事です。
https://qiita.com/advent-calendar/2023/yamap-engineers

はじめに

株式会社ヤマップでフロントエンドエンジニアをしています t-yng です。

先日、ChatGPTの最新バージョンとしてGPT-4 turboが発表されました。このアップデートで、APIのトークン上限が128,000トークン(約300ページの書籍に相当する単語数)まで拡大しています。この進化により、これまで難しかった大量のプログラミングコードを一括で扱うことが可能になりました。

そこで、前から試したかったリファクタリングの自動化が実現できそうだと思ったので、styled-jsxからEmotionへの置き換えを例にCSS in JSの置き換えをサンプルプロジェクトを作って試してみました。

全体の実装はt-yng/replace-css-in-js-gptで確認できます。

自動化の実装プロセス

  1. Emotion実装に書き換えをするプロンプトを作成
  2. コンポーネントのファイル一覧を取得する処理を実装
  3. OpenAI API でコード修正をリクエストして、ファイルを書き換える処理を実装
  4. APIの利用料金を見積もり処理を実装
  5. メイン処理の実装

ステップ1: プロンプトの作成

調整した点は次の3点です。

  • 説明文を省略し、コードのみを出力する指示を追加しています。
  • ファイルを手動で書き換えた結果を具体的な変更前後のコード例として提供して、具体的なコンテキストを与えています。
  • コードが省略されて出力されないように、全てのコードを出力する指示を追加しています。
次の仕様とサンプルコードを元にして、変更対象のstyled-jsxで書かれたReactのコードを既存のコードの全てを含めてemotionを使用して書き換えてください。

説明文は出力せずにコードだけを出力してください。

書き換えたコードをそのままコピーして使いたいので、変更のないコードも省略せずに全て出力してください。

## 仕様
- styled-jsxで記述されたクラス名をキャメルケースに変更して変数名を定義する
- テンプレートリテラルを利用してCSSの実装は変更しない
- メディアクエリも条件を変えずに移行してください
- emotionのCSS定義の変数の適用はcss propsを利用してください

## サンプルコード1
### 変更前
(変更前のコード例)

### 変更後
(変更後のコード例)

## サンプルコード2
### 変更前
(変更前のコード例)

### 変更後
(変更後のコード例)

## 変更対象のコード
(変更したいコード)

プロンプト調整の思考錯誤

プロンプトは特に調整は不要だとだと思っていましたが、実験する仮定でいくつか問題に遭遇してトライ&エラーで調整をしていました。

調整した問題の例として、最初のプロンプトでは冒頭部分を次のように記述していました。

次の仕様とサンプルコードを元にして、変更対象のstyled-jsxで書かれたReactのコードをemotionで書き直してください。
説明文は出力せずにコードだけを出力してください。

この書き方だと、一部のコードが省略されて出力されてしまう問題が発生していました。

export const getStaticProps = async (): Promise<
  GetStaticPropsResult<IndexPageProps>
> => {
  const posts: Post[] = [
    // ... (same content as provided)
  ];
  const tags: Tag[] = [
    // ... (same content as provided)
  ];
};

この問題に対してChatGPTに指示の出し方を問い自分自身でプロンプトを改善させたりと色々と試していたのですが、最終的には以下の指示を追加したことで解消されました。
省略せずにというキーワードを入れただけでは解消されなかったこともあり、プロンプトエンジニアリングの必要性を痛感しました。

書き換えたコードをそのままコピーして使いたいので、変更のないコードも省略せずに全て出力してください。

ステップ2: コンポーネントのファイル一覧を取得する

プロンプトの作成が完了したので、後はAPIをコールしてファイルを書き換える処理をを実装していくだけです。
最初にコンポーネントのファイル一覧を取得する処理を実装していきます。

// gpt/src/AiProgrammer.ts
import { promises as fs } from "fs";
import { join, extname } from "path";

export class AiProgrammer {
  async refactor(srcDir: string) {
    const files = await this.readComponentFiles(srcDir);
    console.log('ファイル一覧:', files);
  }

  private async readComponentFiles(dir: string): Promise<string[]> {
    let files: string[] = [];
    const items = await fs.readdir(dir, { withFileTypes: true });

    for (const item of items) {
      const fullPath = join(dir, item.name);
      if (item.isDirectory()) {
        files = files.concat(await this.readComponentFiles(fullPath));
      } else if (extname(fullPath) === ".tsx") {
        files.push(fullPath);
      }
    }

    return files;
  }
}

ステップ3: GPTモデルによるコード修正

今回の肝であるGPTモデルを利用したコードの自動書き換えを実装します。

最初にOpenAIのGPTモデルにコードの書き換えをリクエストする処理を実装します。

モデルにはgpt-4-1106-previewを指定しており、創造性を発揮せずに常に同じ結果を返してほしいので、パラメーターとしてtemperature=0を設定しています。
また、プロンプトに埋め込む修正前後のサンプルコードを複数dataディレクトリ配下にsample1/before.tsx,sample1/after.tsxとして用意をして、createPromptでそれらのファイルを読み込んでいます。

// gpt/src/GptModel.ts
import { promises as fs } from "fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import OpenAI from "openai";
import { encoding_for_model } from "tiktoken";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const OPENAI_API_KEY = process.env.OPENAI_API_KEY;

class GptModel {
  private MODEL_NAME = "gpt-4-1106-preview" as const;

  async rewrite(code: string) {
    const prompt = await this.createPrompt(code);

    const openai = new OpenAI({
      apiKey: OPENAI_API_KEY,
    });

    const chatCompletion = await openai.chat.completions.create({
      messages: [{ role: "user", content: prompt }],
      model: this.MODEL_NAME,
      temperature: 0,
    });

    const result = chatCompletion.choices[0].message.content;
    if (result == null) {
      return null;
    } else {
      // コードブロックで囲まれているので、コードブロックを取り除く
      return result.split("\n").slice(1, -1).join("\n");
    }
  }

  private async createPrompt(code: string) {
    const sampleCodes = await this.readSampleCodes();

    return `次の仕様とサンプルコードを元にして、変更対象のstyled-jsxで書かれたReactのコードを既存のコードの全てを含めてemotionを使用して書き換えてください。

説明文は出力せずにコードだけを出力してください。

書き換えたコードをそのままコピーして使いたいので、変更のないコードも省略せずに全て出力してください。

## 仕様
- styled-jsxで記述されたクラス名をキャメルケースに変更して変数名を定義する
- テンプレートリテラルを利用してCSSの実装は変更しない
- メディアクエリも条件を変えずに移行してください
- emotionのCSS定義の変数の適用はcss propsを利用してください

${sampleCodes
  .map(
    (sampleCode, i) => `## サンプルコード${i + 1}
### 変更前
${sampleCode.before}

### 変更後
${sampleCode.after}
`
  )
  .join("\n")}

## 変更対象のコード
${code}
`;
  }

  /**
   * サンプルコードを読み込む
   */
  private async readSampleCodes() {
    const dataDir = path.join(__dirname, "..", "data");
    const sampleDirs = await fs.readdir(dataDir);
    const sampleCodes = await Promise.all(
      sampleDirs.map(async (dir) => {
        const before = await fs.readFile(
          path.join(dataDir, dir, "before.tsx"),
          "utf8"
        );
        const after = await fs.readFile(
          path.join(dataDir, dir, "after.tsx"),
          "utf8"
        );
        return {
          before,
          after,
        };
      })
    );

    return sampleCodes;
  }
}

続いて、GPTモデルが返してくれた修正結果でファイルを書き換える処理を実装します。
取得したファイル一覧からコードを読み込んで、GPTモデルへ書き換えリクエストを投げ、受け取った結果でファイルを書き換えています。GPT-4 TurboのAPIはレスポンスに時間がかかるので、高速化のためにリクエストと書き換えを並行処理にしています。

// src/gpt/AiProgrammer.ts
class AiProgrammer{
  private gptModel: GPTModel;

  constructor() {
    this.gptModel = new GPTModel();
  }

  async refactor(srcDir: string) {
    const files = await this.readComponentFiles(srcDir);

    // GPTへの書き換えリクエストとファイルの書き換えを並行実行
    const refactorPromises = files.map(async (file) => {
      const code = await fs.readFile(file, "utf8");
      console.log(`[start]: Refactoring ${file}`);
      const rewrittenCode = await this.gptModel.rewrite(code);
      if (rewrittenCode !== "" && rewrittenCode != null) {
        await this.replaceFile(file, rewrittenCode);
      }
      console.log(`[end]: Refactoring ${file}`);
    });
    const results = await Promise.allSettled(refactorPromises);

    // 書き換えに失敗したファイルを表示
    for (let i = 0; i < results.length; i++) {
      const result = results[i];
      const file = files[i];

      if (result.status === "rejected") {
        console.log(`[error]: Refactoring ${file}`);
        console.log(result.reason);
      }
    }
  }

  // (省略)
}

ステップ4: APIの利用料金を自動見積もり

実際のプロダクトに適用することを想定した場合にコード量が膨大で、想像以上にAPIの利用料金が発生してしまう可能性があります。
APIの利用料金を見積もる処理を実装して、実際にかかる料金を事前に確認できるようにします。

gpt-4-1106-previewの料金体系は次のとおりです。
input: $0.01/1K tokens
output: $0.03/1K tokens

入力コストは生成したプロンプトのトークン数から料金を計算しています。
回答コストは変更対象のコードを書き換えても大きくコード量が変化しないと仮定して、変更前のコードのトークン数から料金を計算しています。

トークン数の計算にはOpenAI公式のトークナイザーモジュールのopenai/tiktokenからフォークされたdqbd/tiktokenを利用しています。

https://github.com/openai/tiktoken
https://github.com/dqbd/tiktoken

// gpt/src/GPTModel.ts
import { encoding_for_model } from "tiktoken";

export class GPTModel {
  async estimateCost(code: string) {
    const prompt = await this.createPrompt(code);
    const enc = encoding_for_model(this.MODEL_NAME);
    const rate = 150; // ドルレート

    // 入力プロンプ卜のトークン数と料金を計算
    const inputTokens = enc.encode(prompt);
    const inputPricing = (0.01 / 1000) * inputTokens.length * rate;
    const roundedInputPricing = Math.round(inputPricing * 100) / 100;

    // 回答のトークン数と料金を計算
    const outputTokens = enc.encode(code);
    const outputPricing = (0.03 / 1000) * outputTokens.length * rate;
    const roundedOutputPricing = Math.round(outputPricing * 100) / 100;

    return {
      input: {
        tokens: inputTokens.length,
        pricing: roundedInputPricing,
      },
      output: {
        tokens: outputTokens.length,
        pricing: roundedOutputPricing,
      },
    };
  }
}

ステップ5: メイン処理の実装

最後に、今まで実装したモジュールを利用して料金見積もりとコードの自動リファクタリングを実行するメインファイルを実装します。

--estimateをオプションとして指定することで、書き換えを行わずに料金見積もりだけを実行しています。

// gpt/src/main.ts
import { fileURLToPath } from "node:url";
import path from "node:path";
import { AiProgrammer } from "./AiProgrammer";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const args = process.argv.slice(2);

async function main() {
  const srcDir = `${__dirname}/../../src`;
  const programmer = new AiProgrammer();

  if (args.includes("--estimate")) {
    const costs = await programmer.estimateCost(srcDir);
    console.log(costs);
    console.log(
      `トークン数の合計: ${costs.reduce(
        (sum, cost) => sum + cost.input.tokens + cost.output.tokens,
        0
      )}トークン`
    );
    console.log(
      `料金: ${costs
        .reduce(
          (sum, cost) => sum + cost.input.pricing + cost.output.pricing,
          0
        )
        .toFixed(1)}`
    );
  } else {
    await programmer.refactor(srcDir);
  }
}

main();

料金見積もりを実行

プロジェクトのコード書き換えを実行する前に、料金見積もりをしておきます。

サンプルプロジェクトの書き換えにかかる料金見積もりは60.3円でした。
これぐらいであれば、安心して実行できます。😀

$ bun gpt/src/main.ts --estimate
[
  {
    file: "/Users/t-yng/workspace/replace-css-in-js-gpt/src/components/home/PostEntries/PostEntries.tsx",
    input: {
      tokens: 1891,
      pricing: 2.8
    },
    output: {
      tokens: 203,
      pricing: 0.9
    },
    ...
  }
]
トークン数の合計: 31988トークン
料金: 60.3

ビジュアルリグレッションによる自動テスト

全てのコードを自動で書き換えるので、意図しない形でデザイン差異が発生する可能性があります。
目視で細かい余白の差異までチェックをするのはかなり大変なので、併せてページ単位での簡単なビジュアルリグレッションテストを準備しておくと非常に便利です。

実際にこのサンプルプロジェクトでも最初の実装で書き換えを実施したときにビジュアルリグレッションテストで余白などのデザイン差異を発見して、プロンプトを何度か修正しています。

自動で書き換えするなら、防衛策として自動テストを導入しておかないと怖いなと思いました。

ビジュアルリグレッションの導入については、過去に自分のブログで記事を書いているので、興味があればご参照ください。
https://t-yng.jp/post/playwright-vrt

書き換えのデモ

自動で書き換えた結果の差分についてはPRを作成してあるので、そちらでご確認できます。

https://github.com/t-yng/replace-css-in-js-gpt/pull/6

さいごに

トークン上限の拡大によって、AIによるコードリファクタリングの現実味がかなり出てきたと実感できました。
実験は成功したので、今度は実際のプロダクトに対して今回の自動リファクタリングの仕組みを適用して、コードをリリースする所まで試したいです。

We are hiring

現在、株式会社ヤマップではアウトドア好きなエンジニアを絶賛募集中です。
https://www.wantedly.com/companies/yamap/projects

YAMAP テックブログ

Discussion