0
0

ES自動生成AIを作った ~コード解説編~ (経歴入力 & 回答生成)

Last updated at Posted at 2024-08-30

今回は以下の拡張機能を作成したときのことについて書いていく。
ES自動生成AIを作った

以下の機能がどの様に動いているのかについて、それぞれコードを追いながら解説していきたいと思う。

構成は以下の様になっている。

フロントエンド
frontend   (モジュールやビルドパッケージについては省略)
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── README.md
├── src
│   ├── components
│   │   ├── Profile.tsx
│   │   └── ProfileForm.tsx
│   ├── contents
│   │   └── index.tsx
│   ├── popup
│   │   ├── index.tsx
│   │   └── routes
│   │       ├── App.tsx
│   │       ├── checkEmail.tsx
│   │       ├── genAnswer.tsx
│   │       ├── generating.tsx
│   │       ├── home.tsx
│   │       ├── logOut.tsx
│   │       ├── openProfileForm.tsx
│   │       ├── signIn.tsx
│   │       └── signUp.tsx
│   └── tabs
│       ├── profile.html
│       └── profile.tsx
├── style.css
├── tailwind.config.js
└── tsconfig.json
バックエンド
backend
├── controller
│   ├── auth_controller.go
│   ├── controllerUtils
│   │   └── controllerUtils.go
│   ├── generate_controller.go
│   └── user_controller.go
├── db
│   └── db.go
├── docker-compose.yml
├── Dockerfile
├── docs
│   └── swagger.yml
├── go.mod
├── go.sum
├── handler
│   └── handler.go
├── infrastructure
│   └── infrastructure.go
├── main.go
├── middleware
│   ├── auth
│   │   └── auth.go
│   └── cors
│       └── cors.go
├── migrate
│   └── migrate.go
├── model
│   ├── generate.go
│   └── user.go
├── README.md
├── repository
│   ├── auth_repository.go
│   ├── generate_usecase.go
│   └── user_repository.go
├── start.sh
├── terraform
│   ├── alb.tf
│   ├── cognito.tf
│   ├── docker-compose.yml
│   ├── ec2.tf
│   ├── provider.tf
│   ├── rds.tf
│   ├── route53.tf
│   ├── variables.tf
│   └── vpc.tf
├── usecase
│   ├── auth_usecase.go
│   ├── generate_usecase.go
│   ├── usecaseUtils
│   │   └── usecaseUtils.go
│   └── user_usecase.go
└── validator
    └── validator.go

経歴入力

フロントのhome.tsxにおいて、CookieのloginStateがlogged-inになっている場合は以下の様にして回答生成ボタンと経歴入力ボタンが表示される

home.tsx
...
if (loginState === "logged-in") {
    return (
      <div className="w-40 h-22">
        <button
          className="block mx-auto bg-blue-500 hover:bg-blue-700 text-white rounded-md w-32 h-8 p-2 mt-4 mb-1"
          onClick={async () => {
            navigate("/generating")
            try {
              await genAnswer()
            } catch (error) {
              console.error("Error generating answer:", error)
            }
            window.close()
          }}>
          回答生成
        </button>
        <button
          className="block mx-auto bg-blue-500 hover:bg-blue-700 text-white rounded-md w-32 h-8 p-2 mt-1 mb-2"
          onClick={openProfileForm}>
          経歴入力
        </button>
        <LogOut />
      </div>
    )
  }
...

もし経歴入力ボタンが押されたときはopenProfileFormというファイルを開く。

openProfileForm.tsx
/// <reference types="chrome"/>

function openProfileForm() {
  console.log("openProfile called")
  chrome.tabs.create({ url: chrome.runtime.getURL("../tabs/profile.html") })
}

export default openProfileForm

そしてここでChormeの拡張機能としてタブを開いて、profile.htmlをレンダリングする。
と言っても、profile.htmlではprofile.tsxに処理を流して、そこでもまた、ProfileForm.tsxに処理を流しているだけである。
なのでProfileForm.tsxを詳しくみる。(Reactではコンポーネントは大文字で始めるのが一般的なのでそれに倣っている)

ProfileForm.tsx
import React, { useEffect, useState } from "react"

import "../../style.css"

import { api_endpoint } from "../contents/index"

const ProfileForm = () => {
  const [bio, setBio] = useState("")
  const [experience, setExperience] = useState("")
  const [projects, setProjects] = useState("")

  useEffect(() => {
    const fetchProfileData = async () => {
      try {
        const response = await fetch(api_endpoint + "/app/profile/getProfile", {
          method: "GET",
          headers: {
            "Content-Type": "application/json"
          }
        })

        if (response.ok) {
          const data = await response.json()
          setBio(data.bio || "")
          setExperience(data.experience || "")
          setProjects(data.projects || "")
        } else {
          console.error("Failed to fetch profile data")
        }
      } catch (error) {
        console.error("Error fetching profile data:", error)
      }
    }

    fetchProfileData()
  }, [])

  const handleProfileSubmit = async (event: React.FormEvent) => {
    event.preventDefault()

    const response = await fetch(api_endpoint + "/app/profile/updateProfile", {
      method: "PATCH",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ bio, experience, projects })
    })

    if (response.ok) {
      alert("Profile saved successfully")
    } else {
      alert("Failed to save profile")
    }
  }

  return (
    <form onSubmit={handleProfileSubmit} className="max-w-lg mx-auto">
      <h2 className="text-xl font-bold mb-4">Profile Information</h2>
      <div className="mb-4">
        <label className="block mb-2">
          自己PR:
          <textarea
            value={bio}
            onChange={(e) => setBio(e.target.value)}
            required
            className="w-full h-24 p-2 border border-gray-300 rounded mb-4"
          />
        </label>
      </div>
      <div className="mb-4">
        <label className="block mb-2">
          経験:
          <textarea
            value={experience}
            onChange={(e) => setExperience(e.target.value)}
            required
            className="w-full h-24 p-2 border border-gray-300 rounded mb-4"
          />
        </label>
      </div>
      <div className="mb-4">
        <label className="block mb-2">
          今まで作った作品:
          <textarea
            value={projects}
            onChange={(e) => setProjects(e.target.value)}
            required
            className="w-full h-24 p-2 border border-gray-300 rounded mb-4"
          />
        </label>
      </div>
      <button
        type="submit"
        className="block mx-auto px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-700">
        Save Profile
      </button>
    </form>
  )
}

export default ProfileForm

このtab内のコードでは経歴の各入力欄にまず以前入力した内容をGETメソッドで取得し、もし以前に入力していたのならばその内容を表示する。こうすることで、更新する時などもいちいち全て入力せずに済む。

そして内容更新なのだが、一般的なHTTPリクエストのメソッドを参考にしてPATCHメソッドを選んだ。
HTTPリクエスト一覧

主にこういう場合はPOSTかPUTかPATCHを使用すると思うが、

  • POST:
    新規リソースの作成
  • PUT:
    既存のリソースの全てを変更
  • PATCH:
    既存のリソースの一部を変更

大まかなイメージはこの様な感じである。更新部分のみを送れたらデータ量が少なく済むと考えてPATCHを選択したが、更新部分のみの処理は一旦置いておこう(めんどくさい)ということでPATCHメソッドだけど内容はほぼPOSTになってしまった。

次にバックエンドだ。handler.goでルーティング処理が行われ、user_controller.goに処理が流される。

user_controller.go
func (uc *userController) GetProfile(c echo.Context) error {
	userRes, err := uc.userUsecase.GetProfile(c)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
	}

	return c.JSON(http.StatusCreated, userRes)
}

func (uc *userController) UpdateProfile(c echo.Context) error {
	input := model.UserProfile{}
	if err := c.Bind(&input); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	if err := c.Validate(input); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	userRes, err := uc.userUsecase.UpdateProfile(c, input)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
	}

	return c.JSON(http.StatusCreated, userRes)
}

最初のGetProfile(controller層)ではビジネスロジック、アプリケーションの具体的な流れをかくusecase層に処理が流される

user_usecase.go
func (uu *userUsecase) GetProfile(c echo.Context) (model.User, error) {
	user, err := uu.userRepo.GetUser(c, c.Get("user_id").(string))
	if err != nil {
		return model.User{}, err
	}

	return user, nil
}

そしてusecase層ではDBなどにアクセスできないため、データアクセスを担当する層のrepository層を呼び出して、すでにデータがあるかどうかを確認している。

user_repository.go
func (r *userRepository) GetUser(c echo.Context, id string) (model.User, error) {
	var user model.User
	result := r.db.WithContext(c.Request().Context()).First(&user, "user_id = ?", id)
	if result.Error != nil {
		return model.User{}, result.Error
	}
	if result.RowsAffected == 0 {
		return model.User{}, result.Error
	}
	return user, nil
}

repository層ではGORMを使用してDBにアクセスしてuserがいるかどうかを確認している。もしuserがいる場合はその中身を取得してフロントに返却している。それをフロントでbioなどの中身をレンダリングすることでユーザーから過去のデータ入力などを見ることができる。

user.go
type User struct {
	UserID     string    `json:"id" gorm:"gorm:unique not null"`
	Username   string    `json:"username" gorm:"unique not null"`
	Email      string    `json:"email"`
	Bio        string    `json:"bio"`
	Experience string    `json:"experience"`
	Projects   string    `json:"projects"`
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
}

次に、UpdateProfileの方だが、こちらでは以下の様な構造体に入力されたデータをバインドして、DBに保存する。先ほどのGetProfileと同じ様な流れになる。

user.go
type UserProfile struct {
	Bio        string `json:"bio" validate:"required"`
	Experience string `json:"experience" validate:"required"`
	Projects   string `json:"projects" validate:"required"`
}

usecase層に処理が流される

user_usecase.go
func (uu *userUsecase) UpdateProfile(c echo.Context, input model.UserProfile) (model.User, error) {
   userID := c.Get("user_id").(string)
   user, err := uu.userRepo.GetUser(c, userID)
   if err != nil {
   	return model.User{}, err
   }

   user.Bio = input.Bio
   user.Experience = input.Experience
   user.Projects = input.Projects

   res, err := uu.userRepo.UpdateUser(c, userID, user)
   if err != nil {
   	return model.User{}, err
   }

   return res, nil
}

ここでもDBにはアクセスできないため、repository層を呼び出してアクセスする。

user_repository.go
func (r *userRepository) UpdateUser(c echo.Context, id string, input model.User) (model.User, error) {
	var user model.User
	result := r.db.Model(&user).Where("user_id = ?", id).Updates(input).WithContext(c.Request().Context())
	if result.Error != nil {
		return model.User{}, result.Error
	}
	return user, nil
}

先ほどと同様にGORMを使用して、求めているuserのデータを呼び出し、そこに新しいデータを入力する。

これで経歴入力および経歴の取得のところはできた。

回答生成

まずはフロントで先ほどと同様にhome.tsxで回答生成ボタンを押す。その後genAnswer.tsxというファイルに飛ぶ。

genAnswer.tsx
/// <reference types="chrome"/>
import { api_endpoint } from "../../contents/index"

async function genAnswer() {
  return new Promise<void>((resolve, reject) => {
    chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
      if (tabs[0] && tabs[0].id !== undefined) {
        chrome.tabs.sendMessage(
          tabs[0].id,
          { action: "getHTML" },
          async (response) => {
            if (response && response.html) {
              const html_source = response.html
              console.log("html loaded")
              try {
                const apiResponse = await fetch(
                  api_endpoint + "/app/generate/generateAnswers",
                  {
                    method: "POST",
                    headers: {
                      "Content-Type": "application/json"
                    },
                    body: JSON.stringify({ html: html_source })
                  }
                )
                if (!apiResponse.ok) {
                  console.error(
                    "Network response was not ok",
                    apiResponse.statusText
                  )
                  reject(new Error("Network response was not ok"))
                  return
                }
                const answers = await apiResponse.json()
                console.log("Received answers:", answers)
                replaceTextareaText(answers)
                resolve()
              } catch (error) {
                console.error("Fetch error:", error)
                reject(error)
              }
            } else {
              console.error("Failed to get active tab HTML.")
              reject(new Error("Failed to get active tab HTML."))
            }
          }
        )
      } else {
        console.error("No active tab found or tab ID is undefined.")
        reject(new Error("No active tab found or tab ID is undefined."))
      }
    })
  })
}

function replaceTextareaText(answers: any) {
  chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
    if (tabs[0] && tabs[0].id !== undefined) {
      chrome.tabs.sendMessage(tabs[0].id, {
        action: "replaceTextareas",
        answers: answers
      })
    }
  })
}

export default genAnswer

このファイルで行なっていることについて書いていく。
まず最初のこの部分で現在開いているタブをアクティブなタブとして認識する。
なのでESの画面以外では機能しなくなっている。

chrome.tabs.query({ active: true, currentWindow: true }

そしてそのアクティブなタブに以下のようにgetHTMLというアクションのメッセージを送っている。ここの送り先は少し後で説明する

chrome.tabs.sendMessage(
          tabs[0].id,
          { action: "getHTML" }

そして取得したhtmlの内容をhtml_sourceという変数に入れて、バックエンドのAPIを叩く。
POSTメソッドで取得してきたhtml_sourceをgenerateAnswersというところに投げ、もし正しく返却出来ていれば、実際にESの画面内に書き込むためにreplaceTextareaTextという関数が走る。

function replaceTextareaText(answers: any) {
  chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
    if (tabs[0] && tabs[0].id !== undefined) {
      chrome.tabs.sendMessage(tabs[0].id, {
        action: "replaceTextareas",
        answers: answers
      })
    }
  })
}

この関数内では先ほどと同様に現在のアクティブなタブに対してreplaceTextareasというアクションのメッセージを送っている。

これと先ほどのgetHTMLというメッセージは以下のcontents/index.tsxというファイル内で処理される。

index.tsx
import type { PlasmoCSConfig } from "plasmo"

export const config: PlasmoCSConfig = {
  matches: ["<all_urls>"]
}

export const api_endpoint = "http://localhost:8080"

chrome.runtime.onMessage.addListener((request, _, sendResponse) => {
  if (request.action === "getHTML") {
    sendResponse({ html: document.documentElement.outerHTML })
  } else if (request.action === "replaceTextareas") {
    const allTextareas = document.getElementsByTagName("textarea")
    Array.from(allTextareas).forEach((textarea, index) => {
      if (request.answers[index]) {
        textarea.value = request.answers[index].answer
      }
    })
    sendResponse({ success: true })
  }
})

ここでもしgetHTMLというアクションメッセージならhtmlを取得するというメッセージの場合とreplaceTextareasの時の処理のそれぞれを記している。
getHTMLの時はそのタブのHTMLを取得する。
replaceTextaresの時は、for文でバックエンドから送られてきたjson形式の配列(問題と回答)をそれぞれ埋め込んでいく。(デバッグのため回答だけでなく、問題も投げている)

これにより、バックエンドにAPIを投げ、正しい配列(問題と回答をjson形式にしたもの)が返ってきた場合は正しく処理できることが分かっただろう。

ではバックエンドを見ていく。
まずはバックエンドのhandler.goでルーティング処理が行われ、generate_controller.goに処理が流される。

generate_controller.go
package controller

import (
	"es-app/model"
	"es-app/usecase"
	"net/http"

	"github.com/labstack/echo/v4"
)

type IGenerateController interface {
	GenerateAnswers(c echo.Context) error
}

type generateController struct {
	generateUsecase usecase.IGenerateUsecase
}

func NewGenerateController(generateUsecase usecase.IGenerateUsecase) IGenerateController {
	return &generateController{
		generateUsecase: generateUsecase,
	}
}

func (gc *generateController) GenerateAnswers(c echo.Context) error {
	var html model.HtmlRequest
	if err := c.Bind(&html); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	if err := c.Validate(html); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	answers, err := gc.generateUsecase.GenerateAnswers(c, html.Html)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
	}

	return c.JSON(http.StatusCreated, answers)
}

この関数では、まず受け取ったjson形式のバインド(Goの構造体に直す)を行う。

model/generate.go
type HtmlRequest struct {
	Html string `json:"html" validate:"required"`
}

それをvalidateを行う。ここでのvalidateとは型の不一致などのエラーを弾くことができる。バインドでも必須フィールドの欠如や型の不一致など基本的なエラーを弾くことはできるが、Validateを使用することでより詳細なエラーハンドリングや、統一性(他の場所でもvalidateを行っている)のために両方とも使用した。

そしてusecase層のgenerateAnswersに処理が流される。

generate_usecase.go
package usecase

import (
	"es-app/model"
	"es-app/repository"
	"es-app/usecase/usecaseUtils"
	"os"
	"strings"
	"sync"

	"github.com/labstack/echo/v4"
)

type IGenerateUsecase interface {
	GenerateAnswers(c echo.Context, input string) ([]model.Answer, error)
}

type generateUsecase struct {
	GenerateRepo repository.IGenerateRepository
	userRepo     repository.IUserRepository
}

func NewGenerateUsecase(GenerateRepo repository.IGenerateRepository, userRepo repository.IUserRepository) IGenerateUsecase {
	return &generateUsecase{
		GenerateRepo: GenerateRepo,
		userRepo:     userRepo,
	}
}

func (gu *generateUsecase) GenerateAnswers(c echo.Context, input string) ([]model.Answer, error) {
	user, err := gu.userRepo.GetUser(c, c.Get("user_id").(string))
	if err != nil {
		return []model.Answer{}, err
	}

	var profile model.UserProfile
	profile.Bio = user.Bio
	profile.Experience = user.Experience
	profile.Projects = user.Projects

	cleanHtml := usecaseUtils.CleanHTMLContent(input)
	bodyContent, err := usecaseUtils.ExtractBodyContent(cleanHtml)
	if err != nil {
		return []model.Answer{}, err
	}

	sendMessage := `以下のHTMLを解析し、textareaのある質問文のみを抽出し、質問文のみを出力してください。出力する際は全ての質問を一つに繋いでください。そして、それぞれの質問文の間には#*#を入れてください。`
	questionsScrapeQuery := sendMessage + bodyContent
	url := `https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key=` + os.Getenv("GOOGLE_API_KEY")
	res, err := gu.GenerateRepo.SendAIRequest(c, questionsScrapeQuery, url)
	if err != nil {
		return []model.Answer{}, err
	}

	questionList := strings.Split(res, "#*#")
	if len(questionList) == 0 {
		return []model.Answer{}, nil
	}

	var wg sync.WaitGroup
	answers := make([]model.Answer, len(questionList))
	for i, question := range questionList {
		wg.Add(1)
		go func(i int, question string) {
			defer wg.Done()
			prompt := usecaseUtils.GeneratePrompt(profile, question)
			answer, err := gu.GenerateRepo.SendAIRequest(c, prompt, url)
			if err != nil {
				return
			}
			answers[i] = model.Answer{Question: question, Answer: answer}
		}(i, question)
	}

	wg.Wait()
	return answers, nil
}

まずはripository層にアクセスして、ユーザーの経歴情報を取得する。(GORMで)

user_repository.go
func (r *userRepository) GetUser(c echo.Context, id string) (model.User, error) {
   var user model.User
   result := r.db.WithContext(c.Request().Context()).First(&user, "user_id = ?", id)
   if result.Error != nil {
   	return model.User{}, result.Error
   }
   if result.RowsAffected == 0 {
   	return model.User{}, result.Error
   }
   return user, nil
}

そしてデータの一部をprofileに代入する。

model/user.go
type User struct {
	UserID     string    `json:"id" gorm:"gorm:unique not null"`
	Username   string    `json:"username" gorm:"unique not null"`
	Email      string    `json:"email"`
	Bio        string    `json:"bio"`
	Experience string    `json:"experience"`
	Projects   string    `json:"projects"`
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
}

毎回これを全部送るのはデータがもったいので、使用するデータのみを送りたい。なので以下のようなprofileに直す

model/user.go
type UserProfile struct {
	Bio        string `json:"bio" validate:"required"`
	Experience string `json:"experience" validate:"required"`
	Projects   string `json:"projects" validate:"required"`
}

そしてHTMLから質問文をスクレイピングするところになる。

ここではどうやって質問文をスクレイピングしているかなのだが、全てAIに投げて質問文をスクレイピングしている。しかし、HTML全文を投げていたら解読に時間がかかったり、精度が低かったりしてしまう。
なので、不要な要素をできる限り削除してからAIに投げようと思う。

cleanHtml := usecaseUtils.CleanHTMLContent(input)
   bodyContent, err := usecaseUtils.ExtractBodyContent(cleanHtml)
   if err != nil {
   	return []model.Answer{}, err
   }

そしてCleanHTMLContentでは以下の様にいらないstyleタグなどを削除している

usecaseUtils.go
func CleanHTMLContent(input string) string {
   re := regexp.MustCompile(`(?s)<!-- Code injected by Five-server -->.*?<!--.*?-->`)
   cleanedHTML := re.ReplaceAllString(input, "")

   reScript := regexp.MustCompile(`(?s)<script.*?>.*?</script>`)
   cleanedHTML = reScript.ReplaceAllString(cleanedHTML, "")

   reStyle := regexp.MustCompile(`(?s)<style.*?>.*?</style>`)
   cleanedHTML = reStyle.ReplaceAllString(cleanedHTML, "")

   return cleanedHTML
}

その後綺麗になったHTMLをusecase層のExtractBodyContentという関数に渡して、それの中のさらにbodyノードのみを探す。

usecaseUtils.go
func ExtractBodyContent(cleanedHTML string) (string, error) {
	doc, err := html.Parse(strings.NewReader(cleanedHTML))
	if err != nil {
		return "", err
	}

	bodyNode := findBodyNode(doc)
	if bodyNode == nil {
		return "", err
	}

	var buf bytes.Buffer
	if err := html.Render(&buf, bodyNode); err != nil {
		return "", err
	}

	return buf.String(), err
}

func findBodyNode(n *html.Node) *html.Node {
	if n.Type == html.ElementNode && n.Data == "body" {
		return n
	}
	for c := n.FirstChild; c != nil; c = c.NextSibling {
		if bodyNode := findBodyNode(c); bodyNode != nil {
			return bodyNode
		}
	}
	return nil
}

この関数内ではまず、HTMLをパースしてHTMLドキュメントツリーにする(DOMツリーとは)

そしてそのDOMツリーの中のタグをそれぞれ探していき、Bodyタグを見つけたらそのタグの中身をバッファにレンダリングして文字列として返却する。
これにより、Bodyタグの中の不要な要素を取り除いたHTMLが作れる。

そしてこれの中かからAIに質問文を見つけさせたい。

質問文をスクレイピング
	sendMessage := `以下のHTMLを解析し、textareaのある質問文のみを抽出し、質問文のみを出力してください。出力する際は全ての質問を一つに繋いでください。そして、それぞれの質問文の間には#*#を入れてください。`
	questionsScrapeQuery := sendMessage + bodyContent
	url := `https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key=` + os.Getenv("GOOGLE_API_KEY")
	res, err := gu.GenerateRepo.SendAIRequest(c, questionsScrapeQuery, url)
	if err != nil {
		return []model.Answer{}, err
	}

ここはプロンプトエンジニアリングなのだが、質問文を配列に入れたいので、一つの質問ずつAIに返却してほしいが、AIからのレスポンスは一文になってしまうので、その対策として#*#という普通の文章には絶対入らないであろう単語を各質問文の間に入れてもらい、それによって質問文を一つずつ抽出している。

generate_usecase.go
package repository

import (
   "bytes"
   "encoding/json"
   "es-app/model"
   "io"
   "net/http"

   "github.com/labstack/echo/v4"
)

type IGenerateRepository interface {
   SendAIRequest(c echo.Context, inputQuery string, inputURL string) (string, error)
}

type generateRepository struct {
}

func NewGenerateRepository() IGenerateRepository {
   return &generateRepository{}
}

func (r *generateRepository) SendAIRequest(c echo.Context, inputQuery string, inputURL string) (string, error) {
   reqBody, err := json.Marshal(model.AiRequest{
   	Contents: []model.Content{
   		{
   			Parts: []model.Part{
   				{Text: inputQuery},
   			},
   		},
   	},
   })
   if err != nil {
   	return "", err
   }

   req, err := http.NewRequestWithContext(c.Request().Context(), "POST", inputURL, bytes.NewBuffer(reqBody))
   if err != nil {
   	return "", err
   }
   req.Header.Set("Content-Type", "application/json")

   client := &http.Client{}
   resp, err := client.Do(req)
   if err != nil {
   	return "", err
   }
   defer resp.Body.Close()

   body, err := io.ReadAll(resp.Body)
   if err != nil {
   	return "", err
   }
   if resp.StatusCode != http.StatusOK {
   	return "", nil
   }

   var geminiResp model.AiResponse
   if err := json.Unmarshal(body, &geminiResp); err != nil {
   	return "", err
   }

   return geminiResp.Candidates[0].Content.Parts[0].Text, nil
}

これは、Geminiに先ほど作成した質問文を投げかけているだけだ。送り方に決まりがあるので、それ以外は特に難しくないと思う。そして送られてきた内容をjson形式から以下の様な構造体に直す

model/generate.go
type AiResponse struct {
	Candidates []struct {
		Content struct {
			Parts []struct {
				Text string `json:"text"`
			} `json:"parts"`
		} `json:"content"`
	} `json:"candidates"`
}

その後以下の様にして一文を一つの質問ずつに直す

generate_usecase.go
	questionList := strings.Split(res, "#*#")
   if len(questionList) == 0 {
   	return []model.Answer{}, nil
   }

そしてそれぞれの質問がquestionListにそれぞれ入ったので、AIに回答生成させる。
今回はなるべく早く回答を生成させるために並列処理を使用しようとしたが、並行処理になってしまった、、、
並列処理と並行処理の違い

generate_usecase.go
   var wg sync.WaitGroup
   answers := make([]model.Answer, len(questionList))

   for i, question := range questionList {
   	wg.Add(1)
   	go func(i int, question string) {
   		defer wg.Done()
   		prompt := usecaseUtils.GeneratePrompt(profile, question)
   		answer, err := gu.GenerateRepo.SendAIRequest(c, prompt, url)
   		if err != nil {
   			return
   		}
   		answers[i] = model.Answer{Question: question, Answer: answer}
   	}(i, question)
   }

   wg.Wait()
   return answers, nil

ここでは各質問に対して一つずつAIに質問文とその文章を投げている。

まずはGeneratePromptでAIに投げる文章を作る

usecaseUtils.go
func GeneratePrompt(profile model.UserProfile, question string) string {
	combinedBio := fmt.Sprintf("%sです。今までの経験は%sです。これまでに作ってきた作品は%s", profile.Bio, profile.Experience, profile.Projects)
	return fmt.Sprintf("あなたの経歴は%sです。以下の質問に答えてください。簡潔かつ具体的に記述し、#や*,-などは使用せずに平文で解答部分のみを出力してください。\n%s", combinedBio, question)
}

ここでもほぼプロンプトエンジニアリングなのだが、経歴や自己PRなどと共に質問に回答してもらうのだが、何も入れないとマークダウン形式で出力されてしまったりするので、これらの様な文章になった。

そしてsendAIRequestという関数は先ほどの質問文をスクレイピングするときに使用したものと同様の関数を使用するので省略する。

そして返却された回答文を一つずつ配列に入れる

answers[i] = model.Answer{Question: question, Answer: answer}

こうしてできた配列をjson形式に直してフロントに返却すれば全ての回答生成の過程が終了する

最後に

これにて全ての主要な機能の解説が終わった。まだまだ付け加えたい機能や修正するべきところはあるが、一旦リリースまでできたことがとても嬉しく思う。
https://chromewebstore.google.com/detail/es-writer-extension/jkencchebhkbaomammmgbhnpalgkchkm?hl=ja
ここでインストールできるのでぜひ使用してほしい

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0