every Tech Blog

株式会社エブリーのTech Blogです。

生成AIを利用したLP実装の自動生成に挑戦してみた

生成AIを利用したLP実装の自動生成に挑戦してみた

目次

はじめに

こんにちは。 トモニテ開発部ソフトウェアエンジニア兼、CTO室Dev Enableグループの庄司(ktanonymous)です。
今回の記事では、生成AIを利用してトモニテが公開しているLPの自動生成に挑戦してみた時の話をしたいと思います。 (※2024年7月下旬〜8月上旬時点での話になります。)

自動生成しようと考えた背景

まずはじめに、なぜLPの自動生成をしようと考えたのか、その背景を説明したいと思います。

トモニテが公開しているLPについて

トモニテでは、企業様と提携したLPを複数公開しています。

LPの例
LPの例

これらのLPは、TypeScript/React/Next.js を利用して実装されており、microCMS1 を利用してコンテンツを管理しています。

各LPは静的ページとしてビルドされ、S3 にデプロイ、CloudFront を経由して配信されます。
また、各LPの仕様書は SpreadSheet/Figma にて管理されており、各LPの実装は仕様書を元に行われています。 基本的には、ビジネスサイドが仕様書を作成し、デザイナーがデザインを作成し、エンジニアが実装を行うというようなフローとなっています。

LP公開までのフロー

既存のLP実装の課題

全てのLPで共通しているパラメータや画像パーツなどのコンテンツは、microCMS を利用して設定できるようになっています。 また、各LPで利用するフォーマットやコンポーネント(プルダウン、選択パネルなど)は一定共通化されていますが、それぞれのLPに合わせて設問や表示方法などが異なっています。
そのため、新しいLPを作成する際には、既存のLPをコピーした後で仕様書を元に細かいチューニングを行う形で開発が行われています。

事業の拡大を目指す中で、LPの数も増えていき、LPの開発・運用に要する人的/時間的リソースの増加がジワジワと事業促進におけるボトルネックとして感じられるようになってきています。
そこで、エンジニアの開発工数やビジネスサイドの確認工数を削減し、事業のスピードアップを図るために生成AIを利用したLPの自動生成を検討しました。

生成AIによるLP実装の自動生成

LP自動生成のためのアプローチ

今回検討した生成AIによるLPの自動生成では、以下のようなフローをイメージしました。

  1. 既存のLPの仕様書および実装を AI モデルに embedding で学習させる。
  2. 新規LPの仕様書およびプロンプトを AI モデルに入力し、LPの実装を生成する。

LP自動生成フロー

生成AIを利用するにあたって、詳細なモデルを決定する以前に、ChatGPT2のようなオープンなモデルを利用するのかローカルLLMを利用するのかという観点があります。 ローカルLLMはモデルの性能が低めであったり実際に使用しているマシンのスペックが影響したりするため精度は落ちがちですが、今回は新しくコストをかけたくないという要望があったので、ローカルLLMを利用することにしました。

実際に自動生成してみて

実際にLPの自動生成を行ってみた時のことについて説明したいと思います。

ローカルLLMに関しては、様々なモデルをシンプルに利用することのできる Ollama3 を採用しました。Ollama は、モデルの重みを量子化して推論を高速化することで CPU レベルのスペックでも LLM を扱えるようにした Llama.cpp4 をラップしてローカルでも動かせるようにしてくれるツールです。 ちなみに、 Ollama で利用できるモデルはこちらから確認することができます。

実際に Ollama を組み込むために、以下の2つのパターンを試してみました。

  1. Open WebUI5 (ローカルで動かせる ChatGPT のような AI チャットツール)
  2. LlamaIndex.TS6 (LLM を組み込んだアプリケーションを開発するための TypeScript 向けフレームワーク)

OpenWebUI を利用した場合

Open WebUI + Ollama による LLM 開発環境を構築できるコンテナイメージが公開されているため、以下のコマンドで簡単にローカル環境を構築できます7

$ docker run -d -p 3000:8080 -v ollama:/root/.ollama -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:ollama

上記のコマンドでコンテナを起動しておいてブラウザから http://localhost:3000 にアクセスするとOpen WebUI を利用した AI チャットツールが利用できます。 設定画面から Embedding のようなモデルの設定などが行えます。

Open WebUI のチャット画面
Open WebUI のチャット画面(Open WebUI 公式サイト

モデルやプロンプトに関しては色々と調整してみましたが、以下のプロンプトを llama3.1 に渡した時に一番良さそうなレスポンスが返ってきました。

プロンプト

`ExistingLP.tsx` was created referencing `existing_lp_specification.csv`.\nLike that, create `NewLP.tsx` referencing `new_lp_specification.csv`.\nYou must show me full implementation.

生成された実装

import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter } from 'next/router';
import EntryAPI from '../api/EntryAPI';

export default function NewLP({ campaign }: Props) {
  const [isError, ToggleError] = useState(false);
  const [isSubmit, ToggleSubmit] = useState(false);
  const router = useRouter();
  const methods = useForm({ shouldFocusError: false });
  const watch = methods.watch();
  const entryAPI = new EntryAPI();

  const onSubmit = (data: DoNotUseAnyType) => {
    ToggleSubmit(true);
    const refererParams = getCommonEventParams();
    const answers = {
      email: data.email,
      gender: data.gender,
      ...  // レスポンスではコードが記述されていましたが省略しています
      // Additional fields from the CSV file
      venue: data.venue,
      experience: data.experience,
    };

    entryAPI.submitEntry(answers, refererParams)
      .then((response) => {
        console.log(response);
        router.push('/thank-you');
      })
      .catch((error) => {
        ToggleError(true);
        console.error(error);
      });
  };

  return (
    <div>
      {isSubmit ? (
        'Thank you for submitting your entry!'
      ) : (
        <form onSubmit={methods.handleSubmit(onSubmit)}>

          ...  // レスポンスではコードが記述されていましたが省略しています

          <label>
            Email:
            <input type="email" name="email" {...methods.register('email')} />
          </label>

          <label>
            Gender:
            <select name="gender" {...methods.register('gender')}>
              {genders.map((gender) => (
                <option key={gender} value={gender}>
                  {gender}
                </option>
              ))}
            </select>
          </label>

          ...  // レスポンスではコードが記述されていましたが省略しています

          <button type="submit">Submit</button>
        </form>
      )}
    </div>
  );
}

とりあえず画面を表示するくらいはできそうな実装が返ってきました。 実際のLPの実装や仕様書をお見せできないので伝わりにくいのですが、既存の実装を踏襲できていない部分や仕様書の内容を反映できていない部分が多くありました。

LlamaIndex.TS を利用した場合

次に、LlamaIndex.TS を利用してローカルで LLM を利用する方法を試してみました。 既存のLPがTypeScriptで実装されているので、組み込みやすいように TypeScript 向けのフレームワークを利用することにしました。 LlamaIndex.TS を利用して TypeScript で Ollama を動かす方法に関しては公式のチュートリアル8が参考になります(Node.js v18からしか対応していません9)。
ここでは、新しいページを作成して、そのページでLLMとのやりとりを行えるようにしようと考えました。 実際の画面や詳細は割愛しますが、このやり方でもあまり良い結果は得られませんでした。

新しいページでLLMを動かすための実装 結果が芳しくなかったので、クエリをページ上から入力できるようにすることまではしていません。

LLM とのチャットページの実装 (src/pages/llm.tsx)

import { useState } from "react";

const LpGenerator = () => {
  // ボタンをクリックするとmain関数が実行されるページ
  const [query, setQuery] = useState("What did the author do in college?");
  const [result, setResult] = useState("");

  const onClick = async (e: any) => {
    e.preventDefault();
    const response = await fetch('/api/query_llm', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ query }),
    });
    const data = await response.json();
    setResult(data.result);
    console.log(result);
  }

  return(
    <>
      <p>LP Generator</p>
      <button onClick={onClick}>Run LLM Query</button>
      {result && <p>{result}</p>}
    </>
  )
};

export default LpGenerator;

API ハンドラーの実装 (src/pages/api/query_llm.ts)

import type { NextApiRequest, NextApiResponse } from 'next';
import QueryLLMAPI from 'api/query_llm';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { query } = req.body;
  const queryLLMAPI = new QueryLLMAPI();
  const result = await queryLLMAPI.post(query);
  res.status(200).json({ result });
}

LLM とのやりとりを行う API の実装 (src/api/query_llm.ts)

import fs from "fs/promises";
import { Document, HuggingFaceEmbedding, Ollama, Settings, VectorStoreIndex } from 'llamaindex';

interface IQueryLLMAPI {
  post(query: string): Promise<string>;
}

export default class QueryLLMAPI implements IQueryLLMAPI {
  constructor() {
    Settings.llm = new Ollama({ model: "llama3.1:8b" });
    Settings.embedModel = new HuggingFaceEmbedding({ modelType: "BAAI/bge-small-en-v1.5", quantized: false });
  }
  async post(query: string): Promise<string> {
    // テキストの読み込みと処理
    console.log("Reading text...");
    const path = "node_modules/llamaindex/examples/abramov.txt";
    const essay = await fs.readFile(path, "utf-8");

    // Documentの生成とインデックスの作成
    console.log("Creating document and index...");
    const document = new Document({ text: essay, id_: path });
    const index = await VectorStoreIndex.fromDocuments([document]);

    // クエリエンジンでクエリを実行
    console.log("Querying...");
    const queryEngine = index.asQueryEngine();
    const response = await queryEngine.query({ query: query });

    // 結果をクライアントに返す
    return response.toString();
  }
}

そのほかのアプローチ

ローカル LLM である Ollama を利用する以外にも、GitHub copilot や OpenAI を利用したアプローチも検討しました(弊社では既に利用可能な状況だったため、一旦「新しい」コストは発生していないという体で考えていました)。 しかし、残念ながら、これでも工数改善に繋がるようなクオリティの生成結果を得ることはできませんでした。

まとめ

今回の記事では、生成AIを利用してトモニテが公開しているLPの自動生成に挑戦してみた時の話をしました。
結果としては、今回の検証では、生成AIを利用してLPの自動生成を行うことは難しいという結論に至りました。 しかし、仕様書のフォーマットや既存実装の渡し方、推論プロセスなど改善できそうな点はまだあると思っています。

大きめの実装を実際に運用できるようなクオリティで生成させることは簡単ではないかと思いますが、今後の AI 技術の発展も含めて、引き続き期待したい領域だと感じました。

LP運用に関しては、引き続き改善を進めていきたいと考えているので、進展があった時にはまた記事にできたらと思います。
最後まで読んでいただき、ありがとうございました。