Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

GPT に PR レビューをやってもらうために試行錯誤した話

こんにちは。Strategic Products Engineering Unit の横山です。
本記事は Sansan Advent Calendar 2023 2日目の記事です。
adventar.org
現在私は Order One というプロダクトを開発しているのですが、そこでの取り組みとして GPT による PR レビューを GitHub Actions で実装しようとした話をご紹介します。

開発に至った経緯

Order One にはエンジニアが現在 8 名在籍していて、Cross Functional な 2 つのチームで主として機能開発を進めています。ただそれだけだと技術的改善が前進しないので、技術的な改善を推進するためにバックエンドとフロントエンドの横軸チームが存在します。
この横軸チームで行った取り決めは ADR(Architecture Decision Records)という手法で Notion 上にドキュメントとしてまとめられています。
横軸チームはそれぞれの技術領域に特に熱い思いを持っている人たちが主導しているのですが、バックエンドやフロントエンドの開発自体は全員が行っています。このため横軸チームで行った取り決めは全員が知っておく必要があります。しかし、単にドキュメントにまとめられているだけだと認識違いがあったり内容を忘れたりして実際のコードに反映されていかないということが多々ありました。
そこで、 *1GPT API を使って ADR に書いている取り決めを参考に自動でレビューをしてもらえたらより情報伝達が容易になるんじゃないかと思って組み込んでみることにしました。

仕組みの解説

実際の処理の流れとしては以下のようになっています。

  • ADR のサマリをキャッシュする(日時で実行)
  1. Notion API で 対象のドキュメントを取得
  2. 各ドキュメントから GPT によってレビュー観点を抽出
  3. Cloud Storage にアップロード
  • レビューを行う(pull_request トリガーによって実行)
  1. GitHub CLI で PR の diff を取得
  2. Cloud Storage から ADR のサマリのキャッシュをダウンロード
  3. diff をファイルごとに分割して、GPT に ADR のサマリを前提としたレビューをしてもらう
  4. GitHub API で レビュー結果を送信

これらの処理を GitHub Actions のワークフロー上で実行させています。
なおGPT のモデルとしてはコスト面とある程度長いトークンを処理する必要があることを考慮して gpt-35-turbo-16k を使っています。
ここからは各処理の詳細をピックアップして解説します。

各ドキュメントから GPT によってレビュー観点を抽出

*2Notion API には Block API というものがあり、これを使うとドキュメントの内容が取得できます。ただこの API のレスポンス形式は単純にドキュメントの内容だけを取得しているだけではなく、ブロックの型情報などのレイアウト情報も含んだ出力になっています。またドキュメントの内容としても参照リンクなど、GPT のレビューには不要と思われる情報も含まれています。なのでこれをそのまま送信すると、トークン数を無駄に消費してしまいます。
なのでこれらのドキュメントに対してレビューに必要な情報を GPT 自体に抽出してもらってまとめを作ることにしました。
これは以下のような Go で書かれたコードで実現させました。

import (
	"context"
	"encoding/json"
	"errors"

	"github.com/sashabaranov/go-openai"
	"github.com/sashabaranov/go-openai/jsonschema"
	"golang.org/x/sync/semaphore"
)

func summaryPage(ctx context.Context, document string) (*adr, error) {
	config := openai.DefaultAzureConfig("AzureOpenAIKey", "AzureOpenAIEndpoint")
	// Function calling を使うために preview を指定。
	config.APIVersion = "2023-09-01-preview"
	client := openai.NewClientWithConfig(config)
	req := openai.ChatCompletionRequest{
		// ちょっと長めのドキュメントに対応するために、16kを指定。
		Model: "gpt-35-turbo-16k",
		Messages: []openai.ChatCompletionMessage{
			{
				Role: openai.ChatMessageRoleSystem,
				Content: `あなたは世界中で信頼されているQ&Aシステムのエキスパートです。
        このドキュメントには Order One というプロダクトの Architecture Decision Record が記述されています。
        この中からあなた自身が Order One のコードに対して PR review を行う上で、役に立ちそうなルールのみを抜き出して指定の json schema の形式で出力してください。`,
			},
			{
				Role:    openai.ChatMessageRoleUser,
				Content: document,
			},
		},
		// 後でマージしたいので json 形式で出力させるために Function calling を使う。
		Tools: []openai.Tool{
			{
				Type: openai.ToolTypeFunction,
				Function: openai.FunctionDefinition{
					Name:       "summarize",
					Parameters: adrSchema,
				},
			},
		},
		ToolChoice: openai.ToolChoice{
			Type: openai.ToolTypeFunction,
			Function: openai.ToolFunction{
				Name: "summarize",
			},
		},
	}

	resp, err := client.CreateChatCompletion(ctx, req)
	if err != nil {
		return nil, err
	}

	answer := resp.Choices[0].Message.ToolCalls[0].Function.Arguments
	adr := &adr{}
	if err := json.Unmarshal([]byte(answer), adr); err != nil {
		return nil, err
	}

	return adr, nil
}

type adr struct {
	Rules []string `json:"rules"`
}

var adrSchema = &jsonschema.Definition{
	Type:        jsonschema.Object,
	Description: "Architecture Decision Record から Order One のコードに対して PR review を行う上で、役に立ちそうなルールを抜き出して格納するための json schema です。",
	Properties: map[string]jsonschema.Definition{
		"rules": {
			Type:        jsonschema.Array,
			Description: "Architecture Decision Record から抜き出したルールのリストです。",
			Items: &jsonschema.Definition{
				Type:        jsonschema.String,
				Description: "Architecture Decision Record から抜き出したルールです。これは Order One のコードに対して PR review を行う上で役に立ちます。",
			},
		},
	},
	Required: []string{"rules"},
}

summaryPage 関数の戻り値として構造体が返ってくるので、ページごとの構造体をマージすればまとめが手に入ります。
コストを考えて、このまとめはレビューのたびに毎回作るのではなくGitHub Actions の schedule 実行機能を使って毎日 1 回作るようにしました。作った結果は Google Cloud Storage のバケット上にアップロードしています。

PRのdiff をファイルごとに分割して、GPT に ADR のサマリを前提としたレビューをしてもらう

PR の diff は GitHub CLI の diff コマンドで取得しているのですが、このコマンドでの出力結果は PR に対するすべてのファイルの差分内容が出力されます。PR の diff が大きい場合、トークンサイズの上限に引っかかってしまうのでファイルごとに分割レビューを行うことにしました。
GitHub CLI の PR の diff の出力結果をみてみると、ファイルごとの区切りとして 'diff --git' で始まる行が出力されているようでしたので、それをもって分割するようにしました。
レビュー結果は Function calling を使って API リクエストの形になるように出力させます。json で出力させることでマージもより簡単にできるようになりました。
これは以下のような Go で書かれたコードで実現させました。

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"

	"github.com/sashabaranov/go-openai"
	"github.com/sashabaranov/go-openai/jsonschema"
	"golang.org/x/sync/semaphore"
)

func reviewFile(ctx context.Context, adrSummery string, document string) (*reviewRequestBody, error) {
	config := openai.DefaultAzureConfig("AzureOpenAIKey", "AzureOpenAIEndpoint")
	// Function calling を使うために preview を指定。
	config.APIVersion = "2023-09-01-preview"
	client := openai.NewClientWithConfig(config)
	req := openai.ChatCompletionRequest{
		Model: "gpt-35-turbo-16k",
		Messages: []openai.ChatCompletionMessage{
			{
				Role: openai.ChatMessageRoleSystem,
				Content: fmt.Sprintf(`あなたは世界中で信頼されているQ&Aシステムのエキスパートです。
        あなたには Order One というプロダクトの PR レビューを行ってもらいます。Order One プロダクトのコードとして順守してほしいコードのルールは以下の json の通りです。
        %s
  
        これらのルールおよび一般的なプログラミングプラクティスを前提として、以下の差分コードをレビューしてください。レビューの観点としてアーキテクチャのルールに反する部分や SQL インジェクション等のセキュリティ面で問題になりそうな変更、一般的なプログラミングプラクティスに反する変更への指摘をおこなってください。
        差分コードは gh diff コマンドによって出力されたものをファイルごとに分割してリクエストしています。
        出力内容は GitHub API を使って  POST します。なのでエラーにならないように出力内容は指定された json schema の制約に必ず従ってください。またコメントは日本語で行ってください。`, "```json"+adrSummery+"```"),
			},
			{
				Role:    openai.ChatMessageRoleUser,
				Content: document,
			},
		},
		Tools: []openai.Tool{
			{
				Type: openai.ToolTypeFunction,
				Function: openai.FunctionDefinition{
					Name:       "summarize",
					Parameters: reviewRequestBodySchema,
				},
			},
		},
		ToolChoice: openai.ToolChoice{
			Type: openai.ToolTypeFunction,
			Function: openai.ToolFunction{
				Name: "summarize",
			},
		},
	}

	resp, err := client.CreateChatCompletion(ctx, req)
	if err != nil {
		return nil, err
	}

	answer := resp.Choices[0].Message.ToolCalls[0].Function.Arguments
	reviewRequestBody := &reviewRequestBody{}
	if err := json.Unmarshal([]byte(answer), reviewRequestBody); err != nil {
		return nil, err
	}

	return reviewRequestBody, nil
}

type reviewRequestBody struct {
	Body     string           `json:"body"`
	Event    string           `json:"event"`
	Comments []*reviewComment `json:"comments"`
}
type reviewComment struct {
	Path      string `json:"path"`
	Position int    `json:"position"`
	Body     string `json:"body"`
}

var reviewRequestBodySchema = &jsonschema.Definition{
	Type:        jsonschema.Object,
	Description: "PR review のための request body です。",
	Properties: map[string]jsonschema.Definition{
		"body": {
			Type:        jsonschema.String,
			Description: "Required when using REQUEST_CHANGES or COMMENT for the event parameter.The body text of the pull request review.",
		},
		"event": {
			Type: jsonschema.String,
			Description: `The review action you want to perform. The review actions "include: APPROVE, REQUEST_CHANGES, or COMMENT. By leaving this blank, you set the review action state to PENDING, which means you will need to submit the pull request review when you are ready.
        This field should always be set to the value COMMENT.`,
		},
		"comments": {
			Type:        jsonschema.Array,
			Description: "PR review のコメントのリストです。",
			Items: &jsonschema.Definition{
				Type:        jsonschema.Object,
				Description: "PR review のコメントです。",
				Properties: map[string]jsonschema.Definition{
					"path": {
						Type:        jsonschema.String,
						Description: "The relative path to the file that necessitates a review comment.",
					},
					"position": {
						Type: jsonschema.Integer,
						Description: `The position in the diff where you want to add a review comment. 
              Note this value is not the same as the line number in the file. This value equals the number of lines down from the first "@@" hunk header in the file you want to add a comment.
              The line just below the "@@" line is position 1, the next line is position 2, and so on. The position in the diff continues to increase through lines of whitespace and additional hunks until the beginning of a new file.
              If this value is specified for a line that does not actually exist, an error will result, so it must be set so that it does not exceed that range.`,
					},
					"body": {
						Type:        jsonschema.String,
						Description: "Text of the review comment.",
					},
				},
				Required: []string{"path", "position", "body"},
			},
		},
	},
	Required: []string{"body", "event", "comments"},
}

reviewFile に事前に作った ADR のまとめとファイルごとの diff を与えれば、構造体が返ってきますので、先ほどと同様に構造体をマージします。
簡略化のため私は reviewRequestBody.Body の情報は切り捨てていますが、ここも GPT にまとめてもらえばよりそれらしい結果が返ってくるかもしれません。
ここで返した json は GitHub のレビュー API のスキーマと揃えているのでこのまま POST すればいいだけという想定でしたが、現時点では行コメントの position の値を GPT が完璧に計算するのは難しいようで、そのまま GitHub API に送信するとエラーが発生する場合があるようでした。なのでエラーが発生した場合行コメントの内容をまとめて一つのコメントとして送信するように対応しました。

感想

レビューの質について

気になるレビューの質についてですが、いくつかの理由から実用するにはまだ改良が必要だなと思っています。

指摘内容と指摘している行がずれる

GitHub API で任意の位置にレビューコメントを表示するには position というプロパティに diff ファイルの位置から数えて何行目かという値を設定する必要があります。しかし GPT 3.5 の場合この値を計算するのが難しいらしく、指摘内容と指摘位置がずれることが多かったです。また position の値が大きくずれている場合、そもそも API の実行に失敗してしまうこともあります。この場合、json の内容をテキストにまとめてそれを送信するようなエラーハンドリングを行う必要がありました。

json の内容をテキストにまとめて送信したときのイメージ(内容はマスクするため編集しています)
指摘内容にノイズが多い

あえて SQL インジェクションを含むようなコードを push してみたところちゃんと指摘してくれたりと、価値ある指摘も行ってくれるのですが、不要な指摘が多く重要な指摘があったとしても埋もれてしまうと感じました。
不要な指摘の例でいうと、ただ単に関数の内容を説明するだけのコメントだったり、 diff をファイル単位で分割した断片的な情報しか渡してない故の間違った指摘をおこなっていたりです。
手元で試してみたところ、diff を分割せずに送信した場合の方がノイズとなる指摘が少なかったように思います。しかしその手法だとトークンサイズの上限に引っかかってしまうため現時点においては解決が難しいと感じました。

Notion 上のドキュメントから作ったサマリの品質が低い

Notion 上のドキュメントから作ったサマリには「○○のため○○すること」のような内容を記載していたのですが、こちらにもノイズが多くレビューの精度を下げる要因になっていたと思います。
Notion で決定事項を書く時には箇条書きでブレークダウンして書いていたため構造に依存するようなドキュメントになっていて、それを GPT が理解できなかったためだと思っています。
例えば以下のような文章だと GPT はそれぞれ別の 2 個のルールだと解釈しているようでした。

- LocalDate.now() を利用しないようにすること
  - タイムゾーンによって意図しない日付になる可能性があるため

この場合前者はよいのですが、後者は理由のみの形でルールとして組み込まれるためノイズになっていたと思われます。
現時点では Notion ドキュメントからルールを生成するよりも直接ルールをプロンプトに埋め込むようにしたほうが早そうでした。

コストについて

最後にコストについても触れておきます。上述した通り Order One は現在 8 人のエンジニアで開発しているのですが、 この仕組みはすべての機能追加の PR の open 時に実行しています。モデルは gpt-35-turbo-16k で Azure を使っているので価格としては 1000 トークンあたり $0.003 が適用されています。この前提で 1 カ月利用してみたところ、 5000 円前後という割と現実的な価格に収まりました。GPT4 だとこの 10 倍の金額になってしまうのでかなりの価格となってしまいますが、近々リリースするであろう GPT4 Turbo はもう少し控えめな価格になっているので実用の範囲内に収まるかもしれません。GPT4 Turbo の場合トークンサイズが大きいので分割して送信する必要がなく、この点も価格を抑えることに対してプラスに働きそうです。

まとめ

GPT に PR レビューを行ってもらうにはまだまだ改良が必要だなーと感じました。
しかし GPT4 Turbo の登場によって実現に向けて一歩前進するのは間違いないですし、近い将来いつか実現できるのだろうなと思っています。
進化の早い技術領域のため追いついていくのは大変ですが、未来を感じられる面白い領域でもあると思っています。今まで以上の開発効率を出すための手段になろうことは確実なので引き続き向き合っていきたいと考えています。

*1:実際には Azure Open AI の API を使っています

*2:Retrieve block children

© Sansan, Inc.