GPTさんに英語の勉強を手伝ってもらう - Function Callingのパースエラー対策

ChatGPT、いろいろ教えてくれますね。
じゃあ英語の勉強用に文章やら確認問題やら作ってもらうといいじゃない、ってやってみたら、よさそう。

日本語の文章を入れると、いい感じの単語数の文章で英訳して、難しい単語とその説明を挙げて、確認問題をつくってくれます。

こんな感じのプロンプトで、%sのところに日本語文が入ります。

You are English tutor. The student has vocablary level with 2000 words.
Make a English text with around 200 words by translating and summerizing the text below.
In addition, choose five difficult words from the English text and make three comprehension questions.
You must use the function `english_text`
---
%s

で、Function Callingでいろいろ設定してもらうんだけど、english_text(String text, String[] words, String[] wordDescriptions, String[] questions)という関数を指定しているイメージ。

{
  "model": "gpt-4-0613",
  "messages": [
    {"role": "system", "content": "%s"}
  ],
  "functions": [
    {
      "name": "english_text",
      "description": "Set the English articles to learn English",
      "parameters": {
        "type": "object",
        "properties": {
          "text": {
            "type": "string",
            "description": "The English text to learn"
          },
          "words": {
            "type": "array",
            "description": "5 difficult words in the text",
            "items": {
               "type": "string"
            }
          },
          "word_descriptions": {
            "type": "array",
            "description": "descriptions about the difficult words",
            "items": {
               "type": "string"
            }
          },
          "questions": {
            "type": "array",
            "description": "3 complehension questions about the text",
            "items": {
               "type": "string"
            }
          }
        },
        "required": ["text", "words", "word_descriptions", "questions"]
      }
    }
  ]
}

ここで、配列を指定するときはtypearrayにして実際の型はitemsに設定するところがミソですね。

"words": {
  "type": "array",
  "description": "5 difficult words in the text",
  "items": {
     "type": "string"
  }
},

そうすると、argumentsにこんな感じのJSONが返ってきます。。。ってオイ、textのなかのダブルクオートがちゃんとエスケープされてないやん!というのが見どころ。

{
  "text": "\"Prohibition of object-oriented programming because it makes programs unreadable\" has ... object-oriented programming to some extent". Object-oriented programming should not be the goal.\"",
  "words": ["prohibition", "unreadable", "mazes", "scattered", "branching"],
  "word_descriptions": [
    "a law, rule, or order that forbids something",
    "difficult or impossible to read",
    "a confusing network of paths or passages",
    "spread out or scattered in various directions",
    "the act of dividing into branches or the state of being divided into branches"
  ],
  "questions": [
    "What is the meaning of 'prohibition' in the text?",
    "What does it mean for code to be 'scattered'?",
    "How would you define 'branching' in programming?"
  ]
}

鍵カッコが原文に入っていると、まず間違いなく失敗する感じなので、GPTに投げなおしリトライやってるとそれだけですぐに10円くらいかかってしまいそう。2Kトークンくらいあるので、一回0.5円。あと、30秒くらいかかるので、4回リトライしてたら2分とかかかって、待ってられません。
ということで、JacksonとかJSONパーサーを使わず自力でパースします。

argumentsをとるところはJacksonでもいいのだけど、そこは難しくないのでこんな感じで取れます。

var arguments = json.lines().map(String::trim)
        .filter(s -> s.startsWith("\"arguments")).findAny()
        .map(s -> parseText(s, "arguments"))
        .orElseThrow(() -> new NoSuchElementException(
                "arguments is not found. it may not be function_call"));

parseTextは最初と最後のダブルクオートとかを取るだけ。

static String parseText(String line, String name) {
    line = line.trim();
    var len = line.length();
    var text = line.substring(name.length() + 5, len - 1 - (line.endsWith(",") ? 1 : 0));
    return text.translateEscapes();
}

パラメータに配列がなければ、argumentsの中身も同じ感じで取ってこれるのだけど、配列は一行にまとまってたり要素ごとに改行されてたり、ちょっとめんどう。

var article = new Article();
String buf = null;
for (String line : arguments.lines().toList()) {
    line = line.trim();
    if (line.endsWith("[")) {
        buf = line;
        continue;
    }
    if (buf != null) {
        buf += line;
        if (line.endsWith(",")) {
            buf += " ";
        }
        if (!line.startsWith("]")) {
            continue;
        }
        line = buf;
    }
    if (line.startsWith("\"text")) {
        article.text = parseText(line, "text");
    } else if (line.startsWith("\"words")) {
        article.words = parseTextArray(line, "words");
    } else if (line.startsWith("\"word_descriptions")) {
        article.wordDescriptions = parseTextArray(line, "word_descriptions");
    } else if (line.startsWith("\"questions")) {
        article.questions = parseTextArray(line, "questions");
    }
}

parseTextArrayはこんな感じ。

static List<String> parseTextArray(String line, String name) {
    line = line.trim();
    var len = line.length();
    var text = line.substring(name.length() + 6, len - 2 - (line.endsWith(",") ? 1 : 0));
    return Arrays.stream(text.split("\", \""))
            .map(String::translateEscapes)
            .toList();
}

GPT-3.5だと、「200単語くらいで」っていうのを聞いてくれないので、一度翻訳だけさせて要約するといいと思います。GPT-4は内容はいいんだけど、function callしてくれないことが多い気がした。
というか、あれ?Chat APIはGPT-3.5とGPT-4は同じ?分量長くなるとGPT-3.5でもGPT-4でも同じくらい時間がかかるので、そうするとGPT-4でよさそう。

コードはこれ。
JSONを自力で解析したので、Jacksonなどの依存なしに標準APIだけで動きます。
https://gist.github.com/kishida/b473e1aabbb375cead2a9e5a7306ea9a