麻雀の結果の計算をする Web アプリを作って Vercel にデプロイした

麻雀の結果の計算をする Web アプリを作った。

プロトタイプとしてざっと作ったので、裏の実装はそれはもうひどいものだが、ひとまず成果物はこちら。

Vite + React + TS

画面のスクリーンショット

手法

AI を使った開発をしたい意図もあったので、以下のようにプロンプトを使ってどんどん実装を生成しては機能追加して、を繰り返した

add functionality to the following code

***functionality***
- player has a name, and the name can be inputted
- if player score is the same, first player will get higher rank. that is, if player 1 and player 2 have the same score, player 1 will get higher rank.

***code***
<< ここにコードを貼り付ける >>

所感

現在は ChatGPT へ課金していないので、無課金の範囲で比べると、 Claude の方が圧倒的に良かった。

ChatGPT で生成されるコードはほとんど意図を汲み取っているとは思えないコードだった。

Claude で生成されるコードは整合性も取れていて、支持していないファイル分割まで出力した。

全てが AI で完結するかというと、やはり言語化が難しい部分は伝える努力をするよりも自分で書いたほうが早い、となる。

状態管理もひどいものなので、大局的な観点を持ちつつ生成してもらうのも難しい(例えば状態管理ライブラリの導入など)

余談

ちなみに、当然同種のツールは既に存在する。

精算ツール | 麻雀

ollama を repository から動かす

Ollama は stop 実装がまだない

実装読む限り signal handling はしてるようにみえるから signal 送ったんだけどプロセスが止まらない

CLI だとどうもbackground で実行してしまうので、開発のように動かしたい。そこで repository を clone して動かす。環境は Ubuntu

https://github.com/ollama/ollama

こちらをclone して、

go generate ./...

go run . serve

generate を忘れると、

llm/llm_linux.go:5:12: pattern build/linux/*/*/bin/*: no matching files found

とエラーが出るので注意。

また、既に ollama を動かしていて、 install 時に install.sh を使っていると、Systemd が使われているので、process が動き続けている。

$ ps aux | grep ollama
ollama      1837  1.4  0.3 11656164 306144 ?     Ssl  15:26   0:03 /usr/local/bin/ollama serve

に対し、 kill -2 しても process が残り続けていた(正しくは何度も再起動されていた)のはそういうわけだった

VScode の extension を作ろうとしていきなりつまづいた

VSCode のオレオレ extension を作りたいと思った。作りたいよね?

Your First Extension | Visual Studio Code Extension API

↑に従って開発を始めた。

global install はしたくない & 普段 pnpm を使っているので、コマンドを微修正しながら進めた。

pnpm init
pnpm install yo generator-code
pnpm yo code

prompt に対する回答は以下のようにした。

     _-----_     ╭──────────────────────────╮
    |       |    │   Welcome to the Visual  │
    |--(o)--|    │   Studio Code Extension  │
   `---------´   │        generator!        │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |     
   __'.___.'__   
 ´   `  |° ´ Y ` 

? What type of extension do you want to create? New Extension (TypeScript)
? What's the name of your extension? code-block-input
? What's the identifier of your extension? code-block-input
? What's the description of your extension? 
? Initialize a git repository? Yes
? Which bundler to use? none
? Which package manager to use? pnpm

その後、Extension Development Host window を開いたのだが、目当ての Hello World が見つからない。 エラーらしきものはないし、QuickStart の一番最初からつまづいた。

console には Deprecation Warning がでているのみ。

(node:91877) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.
(Use `Code Helper (Plugin) --trace-deprecation ...` to show where the warning was created)

パッと思いつく要因は

  • pnpm を使う場合にバグがある
  • pnpm x TypeScript で開発する場合にバグがある
  • extension の名前を kebab-case にしているのがまずい

何度でも作り直しが効くので、何パターンかためしてみることにした。 (余談だが、実行したコマンドのメモをとっておくと、こういう時にコピペで済むので楽だ。)

  • pnpm x JavaScript, project 名を sample にしたら成功
  • pnpm x TypeScript, project 名を sample にしたら成功

これらが成功するということは、 どうやら kebab-case にしたのが原因?と思ったところで、

? Do you want to open the new folder with Visual Studio Code? Open with `code`

の設問が気になった。もしかしたら vscode の project root をどこにするかの問題なのでは?と思って検証したらビンゴ。

要は、

├── vscode-extensions  # ここで pnpm init などを実行
│   ├── sample  # `yo code` によって生成されたファイル 
│   ├── node_modules
│   ├── package.json
│   ├── pnpm-lock.yaml

F5 を押す時に extension の root (上の図なら sample/)を project root として開いていないと Helloworld が見つからない、という話だった模様。

package を global install していたら簡単に気づける問題だったが、生成された directory にも package.json が作られているので混乱した。

kebab-case でも 問題なく extension はつくれた。

Go slog の JSON Handler で入れ子の出力をする

slog の基本

Go1.21 から公式の標準ライブラリに構造化ログを出力可能な slog が追加されました。

呼び出し方

デフォルトで時刻やログレベルが付与されます。 比較のため、fmt でも出力します

fmt.Println("Hello, World!")
slog.Info("Hello, World!")
slog.InfoContext(ctx, "Hello, World!")

出力

Hello, World!
2024/04/13 13:30:48 INFO Hello, World!
2024/04/13 13:30:48 INFO Hello, World!

コード全文

package main

import (
    "context"
    "fmt"
    "log/slog"
)

func main() {
    fmt.Println("Hello, World!")
    slog.Info("Hello, World!")
    ctx := context.TODO()
    slog.InfoContext(ctx, "Hello, World!")
}

追加の情報を付与

key: value pair をログに付与することができます

slog.Info("Hello, World!", slog.Int("line", 9), slog.String("file", "main.go"))
slog.InfoContext(ctx, "Hello, World!", slog.Int("line", 11), slog.String("file", "main.go"))

出力

2024/04/13 13:39:06 INFO Hello, World! line=9 file=main.go
2024/04/13 13:39:06 INFO Hello, World! line=11 file=main.go

コード全体

package main

import (
    "context"
    "log/slog"
)

func main() {
    slog.Info("Hello, World!", slog.Int("line", 9), slog.String("file", "main.go"))
    ctx := context.TODO()
    slog.InfoContext(ctx, "Hello, World!", slog.Int("line", 11), slog.String("file", "main.go"))
}

JSON 形式で出力

出力の形式は handller で調整可能で、デフォルトでは TextHandler が使われます。

実用上便利なことが多いので、実際の開発では JSON Handler はよく使われます

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, nil)
    logger := slog.New(jsonHandler)
    ctx := context.TODO()

    logger.Info("Hello, World!", slog.Int("line", 15), slog.String("file", "main.go"))
    logger.InfoContext(ctx, "Hello, World!", slog.Int("line", 16), slog.String("file", "main.go"))
}

出力

{"time":"2024-04-13T13:53:07.414249+09:00","level":"INFO","msg":"Hello, World!","line":15,"file":"main.go"}
{"time":"2024-04-13T13:53:07.414599+09:00","level":"INFO","msg":"Hello, World!","line":16,"file":"main.go"}

コード全体

package main

import (
    "context"
    "os"

    "log/slog"
)

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, nil)
    logger := slog.New(jsonHandler)
    ctx := context.TODO()

    logger.Info("Hello, World!", slog.Int("line", 15), slog.String("file", "main.go"))
    logger.InfoContext(ctx, "Hello, World!", slog.Int("line", 16), slog.String("file", "main.go"))
}

余談ではありますが、比較すると、人間的には(個人的には) Text の方が読みやすいと思いました

# TextHandler
2024/04/13 13:39:06 INFO Hello, World! line=11 file=main.go
# JSONHandler
{"time":"2024-04-13T13:50:21.753606+09:00","level":"INFO","msg":"Hello, World!","line":15,"file":"main.go"}

slog の AddSource Option を探索

slog.NewJSONHandler の第2引数には Option を渡せます。 AddSource Option は Stacktrace まではいきませんが、コードの場所は教えてくれます。

...
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    })
    logger := slog.New(jsonHandler)
...

出力

{"time":"2024-04-13T13:56:09.692728+09:00","level":"INFO","source":{"function":"main.main","file":"/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go","line":15},"msg":"Hello, World!"}

コード全体

package main

import (
    "os"

    "log/slog"
)

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    })
    logger := slog.New(jsonHandler)

    logger.Info("Hello, World!")
}

どのファイルのどの関数で、何行目か、出力されてます。 ちなみに、 build しても source map の情報は維持されているようです

% go build -o main
% ./main 
{"time":"2024-04-13T14:04:38.670391+09:00","level":"INFO","source":{"function":"main.main","file":"/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go","line":15},"msg":"Hello, World!"}

入れ子で出力する

struct の場合

構造体を出力したい場合は、json タグをつければ出力されます

package main

import (
    "os"

    "log/slog"
)

type Address struct {
    City  string `json:"city"`
    State string `json:"state"`
}

type User struct {
    Name    string   `json:"name"`
    Age     int      `json:"age"`
    Address *Address `json:"address,omitempty"`
}

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    })
    logger := slog.New(jsonHandler)
    u := &User{
        Name: "Alice",
        Age:  25,
        Address: &Address{
            City:  "San Francisco",
            State: "CA",
        },
    }

    logger.Info("Hello, World!", "user", u)
}

出力

{"time":"2024-04-13T14:17:13.399365+09:00","level":"INFO","source":{"function":"main.main","file":"/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go","line":34},"msg":"Hello, World!","user":{"name":"Alice","age":25,"address":{"city":"San Francisco","state":"CA"}}}

見づらいので整えると、以下のように出力が得られていることがわかります

{
  "time": "2024-04-13T14:17:13.399365+09:00",
  "level": "INFO",
  "source": {
    "function": "main.main",
    "file": "/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go",
    "line": 34
  },
  "msg": "Hello, World!",
  "user": {
    "name": "Alice",
    "age": 25,
    "address": { "city": "San Francisco", "state": "CA" }
  }
}

ちなみに json タグがない場合、struct のフィールド名が key として使われます

// タグをなくす
type Address struct {
    City  string
    State string
}


func main() {
...
    logger.Info("Hello, World!", "user", u)
}

出力

{"time":"2024-04-13T14:36:16.765509+09:00","level":"INFO","source":{"function":"main.main","file":"/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go","line":34},"msg":"Hello, World!","user":{"name":"Alice","age":25,"address":{"City":"San Francisco","State":"CA"}}}

コード全体

package main

import (
    "os"

    "log/slog"
)

type Address struct {
    City  string
    State string
}

type User struct {
    Name    string   `json:"name"`
    Age     int      `json:"age"`
    Address *Address `json:"address,omitempty"`
}

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    })
    logger := slog.New(jsonHandler)
    u := &User{
        Name: "Alice",
        Age:  25,
        Address: &Address{
            City:  "San Francisco",
            State: "CA",
        },
    }

    logger.Info("Hello, World!", "user", u)
}

*"City", "State" と、先頭が大文字になっている(フィールド名が使われている)

group で出力する

同じ出力を slog.Group を使って再現できます。実際は struct なら group は使わず直接渡した方が楽です。

package main

import (
    "os"

    "log/slog"
)

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    })
    logger := slog.New(jsonHandler)

    logger.Info(
        "Hello, World!",
        slog.Group(
            "user",
            slog.String("name", "Alice"),
            slog.Int("age", 25),
            slog.Group(
                "address",
                slog.String("city", "San Francisco"),
                slog.String("state", "CA"),
            ),
        ),
    )
}
{"time":"2024-04-13T14:26:48.645613+09:00","level":"INFO","source":{"function":"main.main","file":"/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go","line":15},"msg":"Hello, World!","user":{"name":"Alice","age":25,"address":{"city":"San Francisco","state":"CA"}}}

余談

公式 GoDoc 曰く、

logger.LogAttrs(ctx, slog.LevelInfo, "hello", slog.Int("count", 3))

と呼ぶことが最も効率がいいようです。 LogLevel の指定は面倒なので、プロジェクトの中でこれで統一するなら、snippet を用意したいなと思いました。

OpenAI の API を叩く AI Agent を Python で作り、Go の開発を全部 AI にやってもらいたい!

こんにちは、a2 です。

Go のアプリケーションのコードをモリモリ生成してくれる AI Agent を作ることができないかなと思い、試してみてます。タイトルに書いたようなことはまだ全然できてないのですが、今回はその第一歩を書いてみます。

背景

今、開発現場における AI 活用は、GitHub Copilot を使って、エディターで開いているファイルを元に近しいコードを生成したり、関数のコメントから実装を生成してもらう、といった使い方が主流かと思います。 私も、Go の Table Driven Test のコードを書いてもらえるのがめちゃめちゃ助かってます。

しかし、これはあくまでソフトウェアエンジニアリングにおける汎用的な AI 活用方法に過ぎず、個別具体のケースでは最適化の余地が多くあると思っています。

例えば、 GitHub Copilot が生成したコードはコンパイルエラーを含んでいたり、正しく動作しないことが多々あります。 自分で修正することも可能ですが、生成されたコードとその問題点 (error message など) を ChatGPT や GitHub Copilot Chat に打ち込めば、コードをさらに修正した結果が得られます。 コンパイルエラーは、人が何かを判断する必要はありません。機械の側で認識できます。 であれば、コードを生成した後、コンパイルできるかをチェックして、エラーがあれば再度生成し直す、というところまで出来るはずです。 動作の検証だって、テストコードがあれば、コードを生成した後にテストを実行して、エラーがあったらさらに生成し直す、までできるはずです。

つまり、以下は当然のように自動で実行できるはずなのです。

  1. 要件を入力してコードを生成する
  2. コードを検証する(コンパイル、テスト実行、アプリケーション実行)
  3. 生成結果と検証結果を踏まえて再度生成する
  4. 2 と 3 をエラーがなくなるまで繰り返す

AIで置き換えたい実装フロー

とはいえ、 AI モデルの token の制限、実装の手間を考えると、現実はやってみたいと分かりません。 一般のソフトウェアエンジニアがどこまでできるのか、OpenAI の Chat API の function call を活用しながら実現可能性を探っていきます。

function call とは

ChatGPT の API には function call (今は tool call に変えようとしている?) なるものがあります。これは、関数の名前と処理内容をプロンプトと一緒に送ると、関数を呼ぶかどうか、どの関数をどんな引数で呼ぶとよいかを判断して結果を返してくれます。function call により、 Open AI の Chat API は単なるテキスト生成エンジンを超え、プログラムの実行や、より複雑なタスクを解決するためのオーケストレーションツールとして使うことができます。

autonomous agent について

OpenAI API の function call が代表的な実装方法ですが、与えられた課題に対して検索や生成などを自分で判断した上で実行し、タスクの完了を自動で目指す動きをするソフトウェアを、autonomous agent (自律的エージェント) と呼びます。

具体的なソフトウェアとしては AgentGPT が有名です。Web 上で簡単に動かせるようになっている ので、触ってみるとどんな感じかつかみやすいと思います

この定義を踏まえると今回は、自作 autonomous agent をやってみた、に近い記事になります。

やりたいこと

Agent に簡単な TODO アプリを作ってもらうことにします。

究極的には、「TODO アプリをGo で作って」と言ったらレポジトリがまるごと出来上がるような状態を作れないかな、と妄想しています。

もちろん現実には、詳細な機能要件・非機能要件を言語化して指定する必要があったりしますが、今回は自律的に開発を進めるためのおおまかな流れが実現できることをゴールに設定しました。

開発

生成したいコードは Go のコードですが、OpenAI の API に request する agent 自体は Python で書いていきます。なお、langchain は使っていませんが、使い方を学ぶのが面倒だったのと、他の言語で実装することを考えての判断です。

function の列挙

ファイルにコードを書く、 go run を実行する、など、 呼び出し可能な関数と、その説明を用意しておきます

definitions = [
  {
    "type": "function",
    "function": {
      "name": "write_to_file",
      "description": "Overwrite a whole file with given contents. Create the file if not exist. returns the whole file contents as a result of mutation",
      "parameters": {
        "type": "object",
        "properties": {
          "filename": {
            "type": "string",
            "description": "filename to write"
          },
          "content": {
            "type": "string",
            "description": "content to write"
          }
        }
      },
    }
  },
...
  {
    "type": "function",
    "function": {
      "name": "go_run",
      "description": "Execute go run {filename}",
      "parameters": {
        "type": "object",
        "properties": {
          "filename": {
            "type": "string",
            "description": "filename to write"
          },
        },
      },
    },
  },
...
]

OpenAI の Chat API は、この json の description を見て関数の内容を把握し、必要に応じて「この関数をこの引数で呼び出せ」という response を返してきます。

用意した関数をいくつか抜粋すると、

  • ファイルの新規作成+書き込み(べき等なファイル書き込み)
  • ファイルへの追記
  • ファイルの読み込み
  • go run
  • go test
  • 任意のコマンド実行

です。"任意のコマンド実行" はさすがに怖いので、実行前に確認を強制しています。

function の実装

呼び出される関数も実装しておきます。

def write_to_file(filename: str, content: str) -> str:
  with open(filename, 'w') as f:
    f.write(content)
  return f"""finished. the contents of the file:
{read_file(filename)}"""  # read_file はファイルの中身を読む helper です。


def go_run(filename):
  if not filename.endswith(".go"):
    return "file is not go file"
  command = f"go run {filename}"
  return _run(command) # _run は コマンドを実行する helper です。

write_to_file は実行結果としてファイルの中身を返していますが、token を消費するので、なくても良いかもしれません。

function の呼び出し OpenAI の Chat API の response には呼び出すべき function と その 引数が含まれるので、対応する関数を以下のように呼び出します。

      getattr(gpt_functions, function_to_call.name)(**arguments)

(普段 Go を触っているので、メタプログラミング的な関数実行にむずがゆさがありますが、とても便利です。)

最初の指示

一番最初に与えるプロンプトは適当に考えました。より良い結果を得たい時、改良の余地が一番大きそうです。

You need to create a todo app in Go.

You need to develop this app in test driven development.

after you generate code, you write this to a file and run it.

if any error or bug is found, you need to fix it.

if you have question to me, you can ask me.

「TODO アプリを Test Driven に開発してね」ということを雑に伝えています。後述しますが、通常はここで簡単に要件を伝えるのが良さそうです。

メインロジックの実装

API 呼び出しのロジック部分は、細部を端折ってますが、以下のようになります。

import os
import json

import openai
import gpt_functions

openai.api_key = os.getenv("OPENAI_API_KEY")

client = openai.OpenAI()

first_prompt = ...

def run_app():
  message = {
    "role": "user",
    "content": first_prompt
  }

  run_conversation([message])

def run_conversation(history):
  response = client.chat.completions.create(
    messages=history,
    model="gpt-3.5-turbo-0125",
    tools=gpt_functions.definitions,
    tool_choice="auto",
  )

  assistant_message = response.choices[0].message
  history.append(assistant_message)

  if assistant_message.tool_calls and len(assistant_message.tool_calls) > 0:
    for tool_call in assistant_message.tool_calls:
      function_to_call = tool_call.function

      try:
        args = json.loads(function_to_call.arguments)
      except:
        print("ERROR: could not parse json")
        return 

      function_response = getattr(gpt_functions, function_to_call.name)(**args)

      next_message = {
        "role": "tool",
        "content": function_response,
        "tool_call_id": tool_call.id,
      }
      history.append(next_message)
    run_conversation(history)
  else: # 通常の Chat 

最初の prompt で「TODO アプリを作ってください」 と指定して、function を列挙した object (gpt_functions.definitions) と共に Chat API に request します。

response に tool_call (function call の新しい呼び名) があれば、対応する function を呼び出し、結果を history に追加しながら再帰的に Chat API に request します。 tool_call は複数帰ってくることもある(例えばファイルへの書き込みをした上で、 go run . を実行する、など)ので、loop を回すようにしています。

なお、実際に書いたコードでは、過程を追うためにログをたくさん仕込んだり、暴走を防ぐために各所で実行を継続してよいかの確認をするようにしています。

下手をすると無限にループして API 利用料がかかってしまうので注意してください

実行結果

関数実行

実際に動かすと、

  1. go mod init todo の実行
  2. go get github.com/stretchr/testify の実行
  3. todo_test.go の生成
  4. go format の実行
  5. go run の実行
  6. (chat) セットアップ終わったけど次はどうする?と聞く(←人(私)が test だけじゃなくてメインのロジックも書いて、と回答)
  7. todo_app.go の生成 (実装はコメントのみで、何もかかれていない状態)
  8. go format の実行
  9. go test の実行
  10. (chat) todo_app.go ができたよ。まだ何か必要だった教えて、と聞く(←人(私)がこのアプリは todo-item の追加と削除ができる必要があります、と要件を追加)
  11. go run の実行
  12. todo_test.go の生成
  13. todo_app.go を生成

といった操作になりました。(chat) という書いている部分は function call ではなく、chat として応答が帰って来たステップです。

どんなライブラリを使うか、どんなファイル名でファイルを作るかなどを判断して自動で動いていることがわかります。 生成されるコードの中身がコメントになったりするのは、要件を精緻に伝えれば改善されるかもしれませんが、そこまでやるなら自分で書いたほうが早いともなるので、難しいところです。

最初のプロンプトで TODO アプリの要件を一切指定していなかった(雰囲気で作ってくれると期待していた)ので、最初に生成されたアプリは中身がない空っぽのコードでした。 中身のないアプリを実装してから「できたよ」と言ってきたのは残念でしたが、要件を伝えていなかった側が悪いです。

生成されたコード 少し長いですが、以下が生成された todo_app.go です。

package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {

    // Create an empty slice to store todo items
    var todoItems []string

    for {
        // Display the menu options to the user
        fmt.Println("Choose an action:")
        fmt.Println("1. Add todo item")
        fmt.Println("2. Delete todo item")

        // Read user input for the action
        fmt.Print("Enter your choice: ")
        reader := bufio.NewReader(os.Stdin)
        choice, _ := reader.ReadString('\n')

        // Remove newline character from the choice
        choice = choice[:len(choice)-1]

        // Perform the selected action
        switch choice {
        case "1":
            // Add todo item
            fmt.Print("Enter the title of the todo item: ")
            title, _ := reader.ReadString('\n')
            title = title[:len(title)-1]
            todoItems = append(todoItems, title)
        case "2":
            // Delete todo item
            if len(todoItems) == 0 {
                fmt.Println("No todo items to delete")
            } else {
                fmt.Println("Existing todo items:", todoItems)
                fmt.Print("Enter the index of the todo item to delete: ")
                var index int
                fmt.Scanf("%d", &index)
                if index >= 0 && index < len(todoItems) {
                    todoItems = append(todoItems[:index], todoItems[index+1:]...)
                } else {
                    fmt.Println("Invalid index")
                }
            }
        default:
            fmt.Println("Invalid choice. Please try again.")
        }
    }
}

todo item の List を要件で伝え損ねたので、add/delete のみしかできませんが、最低限の例外ケース含めて、うまくできています。

実行してみると、以下のように、動いています。

$ go run todo_app.go 
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: clean the room
Invalid choice. Please try again.
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 1
Enter the title of the todo item: clean the room
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 1  
Enter the title of the todo item: buy food
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 2
Existing todo items: [clean the room buy food]
Enter the index of the todo item to delete: 2
Invalid index
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 2
Existing todo items: [clean the room buy food]
Enter the index of the todo item to delete: 1
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 2
Existing todo items: [clean the room]
Enter the index of the todo item to delete: 

後から add/delete を実装するという要件を伝えた後、 todo_test.go を生成してくれましたが、中身は空っぽで放置されていました。

Test Driven の説明をする際に、"一度テストを失敗させる" ステップを伝えていなかったので、test が成功 = OK と判断した可能性は高いです。

package todo

import (
    "testing"
)

func TestAddTask(t *testing.T) {
    // implement the test
}

func TestCompleteTask(t *testing.T) {
    // implement the test
}

その後

上記のように、最初にダーッと作ったものでもそれなりに動くものが作れたので、「改善すればもっとよくなるのでは??」と期待が高まりました。 しかし、ls cat コマンド相当の function を用意したり、go のコマンドを追加したり、選択肢を増やし、最初の prompt にアプリの要件を追加したりと色々追加しても、最初より良い結果が得られませんでした。

具体的には、 go test を実行してエラーを見つけるところまではいいのですが、その修正として生成されたコードが修正前と差分がなく、無限ループしてしまったり、実装をコメントで // implement here で済ませてしまったり。モデルが扱えるコンテキストの大きさの限界であれば、最初に全て伝えるのではなく、順次実装を進めてもらう形が良いかもしれません。

他にもまだまだ工夫の余地はたくさんあり、

  • より大きいモデルを使ってみる
  • 読み込ませるファイルとコード量を制限・選択する
  • プロンプトを改善する
  • 全体の設計を調整する agent とコードを実装する agent など、複数 agent を用意する

あたりは試してみたいなと思います。

他に気になったこととして、そもそも変更の差分を示す git の commit log が gpt モデルの学習に使われているのか気になりました。もし学習されていれば、コードの状態の遷移を生成する期待ができますが、コードを snapshot として学習していた場合、完成形の状態を生成するのには向いていても、既にあるコードを改修していく方向には活かしにくいかも、、などと考えていました。

先行研究もいくつかあるので、次は論文も漁ってみたいと思います、詳しい方がいらっしゃったら要チェックの論文を教えてください。

Postmanを使用したブラウザCookieの同期と効率化

はじめに

Web 開発において、ブラウザCookieの効率的な使用は、APIテストの精度を向上させる上で重要です。

Postman では Cookie を手動で入力することも可能ですが、Developer tools から cookie をみてコピー&ペーストする作業を繰り返し行うのは無駄が大きいです。

この記事では、Postmanを使用してブラウザCookieを同期し、APIテストと開発プロセスを効率化させる方法に焦点を当てます。

手順

手順は全て公式サイトに記載がありますので、ここでは概要を記します。

Chrome拡張機能のインストール

必要な拡張機能をインストールしてください

Postman Interceptor - Chrome ウェブストア

Cookieの同期

Chrome拡張機能とPostman間でCookieを同期します。 インストールした拡張機能を有効化し、アイコンをクリックし、"Sync Cookies" のタブに切り替えます。

Postman 拡張機能の window

取得したい Cookie の domain を入力して、 "Sync Cookies" を押します。

すぐに "Stop Cookies" を押しても、Cookie の同期自体は完了しています。

Postman の画面の右上

Postman の画面の右上に "cookies" とあるので、開きます

cookies を開いた時の画面

同期したい cookie の key をクリックすると、"save" すれば Postman の request にも反映されます。

はまったところ

ドメイン指定で、 localhost:5173 を指定したため、cookie が sync されなくてしばらく困りました。 正しくは localhost を使用する必要があります。ドメインは port 番号を含みません。

セキュリティに関する考慮事項

今回見たように、拡張機能cookie へのアクセスが可能です。

不要な時は拡張機能をオフにしておくと良いでしょう。

また、日常的に、信頼できる拡張機能の選択が重要です。開発者の信頼性、レビュー、アクセス許可を確認してください。

ogen の convenient error の用途と使い方をメモ

ogen を使うとログに出力される INFO がある

(ogen とは、OpenAPI の Go サーバーコード生成ツールの一つ。ogen については他の記事を参照のこと。)

ogen を使い始めるとすぐ、コード生成コマンドで以下の info がログに出ることに気づく

INFO convenient Convenient errors are not available {"reason": "operation has no \"default\" response", "at": "file:///h/o/g/e/hoge.yaml:5:5"}

Convenient Error が使えなかった、とのこと。

Convenient Error が何かは知らないが、 Convenient な Error なのだから、API Schema 全体で共通して定義できるエラーなど、なんらかコードを書く量を減らせる類の機能だと予想される。

実際どうか、調べてみた。

公式 document をみてみる

公式 document

ogen は document が充実しているとはいえない(2023/12/17 時点)のが難点だが、独自の概念であるからか Convenient Errors については記載がある

以下の 2 条件を満たしたときに使える機能らしい

  • すべての operation で同じデフォルトのレスポンスが定義されている
  • このレスポンスはapplication/jsonメディアのみを定義している

まぁまぁ厳しい条件な気もするが、そもそも endpoint によらないエラーを定義する機能なんだと考えれば納得はいく

どんな目的で使うか

ogen では interface を生成するが、その実装時、事前定義されているエラーについては一つ目の返り値で返すことが想定されている。 API Schema に 404 が定義されていれば、 一つ目の返り値で 404 に相当する構造体を返す。(これはこれで面白いが、割愛)

二つ目の返り値は error interface を満たす値だが、これはアプリケーションの想定外の場合に返すイメージ。

Convenient Error を使わない通常の生成の場合、二つ目の返り値があると、500 が返され、err.Error の中身が message として返る。

Convenient Error は、二つ目の返り値があるときの挙動に共通してロジックを適用できる機能だ。 具体的な用途でパッと思い作るのは、

  • エラーの種類によって通知を分ける実装を挟みたい
  • エラーメッセージの中身をクライアントに見せたくない
  • その他エラーに関する独自ロジックを挟みたい

といったところだろうか。

これらは interceptor のような middleware で実装するイメージだが、ogen では middleware はexperimental だし、ogen が生成するコードが内部で response を書き込んでいるので、ogen の外側の middleware ではresponse の中身を調整する類の実装は難しそうだ。

実装

公式 doc にあるように、API スキーマに記述すべきことは以下だ。

  • components/schema に エラー型を定義
  • 全ての response に default response を追記し、同じ記述をする

コード生成後は Handler で error interface を満たすように実装すればよい。

例えばアプリケーション内部で発生するエラーを、サーバー起因のエラーとユーザー起因のエラーのどちらかに分類し、それをエラーメッセージに含めているとすれば、公式サイトの例について、以下のように実装できる。

type TrivialHandler struct{}

func (TrivialHandler) MeGet(ctx context.Context) (r *api.User, _ error) {
    me, err := doSomething()
    if err != nil {
        return nil, err
    }

    return &api.User{ID: me.ID}, nil
}

func (TrivialHandler) UsersPost(ctx context.Context, req *api.User) error {
    // implement
    return ht.ErrNotImplemented
}

func (TrivialHandler) NewError(ctx context.Context, err error) (r *api.ErrorStatusCode) {
    r = new(api.ErrorStatusCode)
    if strings.Contains(err.Error(), "[UserError]") {
        r = new(api.ErrorStatusCode)
        r.StatusCode = 400
        r.Response = api.Error{
            Code:    113355,
            Message: err.Error(),
        }
        return r
    }
    if strings.Contains(err.Error(), "[ServerError]") {
        fmt.Println("sending error to sentry...")
        // TODO: send error to sentry

        r = new(api.ErrorStatusCode)
        r.StatusCode = 500
        r.Response = api.Error{
            Code:    555000,
            Message: "please try again later",
        }
        return r
    }
    r.StatusCode = 500
    r.Response = api.Error{
        Code:    555000,
        Message: "unknown error",
    }
    return r
}

実際はカスタムエラーなどを定義して、型判定等をするのが良いと思う。

まとめ

ogen 独自の拡張として Convenient Error というものがあり、想定外の error をどう処理するか、API サーバー全体を横断して定義できる。

おまけの一言

ogen の開発が続いていくか心配です