Geminiにスケジュールアシスタントをしてもらう

Gemini APIが公開されてから、早くも1か月がたちました。
マルチモーダルにチャット、いろいろなことができるGeminiですが、「関数呼び出し」という機能があります。
今回は、その関数呼び出し機能を使って、Geminiにスケジュールアシスタントをしてもらおうと思います。

Geminiの関数呼び出しとは?

Geminiには、「関数呼び出し」の機能があります。
Geminiを呼び出す際に、関数の定義を渡してあげると、必要に応じて関数の名称と生成したパラメータを返してくれます。
関数の定義には実装は含まれていません。「関数の名前」「その関数がどのような処理を行うのかという説明」「その関数に必要なパラメータの定義」で構成されています。
「関数呼び出し」で直接API等を呼んでくれるわけではありませんが、Geminiが会話の内容を把握して、定義済みの関数を組み合わせて、状況に応じたAPIを選択し、そのAPIのパラメータも生成して呼び出してもらうことができるため、実装によっては、あたかもGeminiが色々やってくれているようなアプリを作成することが可能です。

スケジュールアシスタントの実装概要

今回実装する機能では、スケジュールの検索と登録ができるようにします。弊社ではOutlook Onlineを使用しているので、APIの呼び出しにはMicrosoft Graph APIを使用します。

関数は「検索」と「登録」に加えて、「現在時刻」の3つを用意します。
「現在時刻」の関数を用意することによって、明日や今日といった指定が可能になります。

実際の呼び出しイメージは図のようになります。
[1] 始めに、ユーザの入力を関数定義とともにGeminiに送ります。
[2] 関数の実行が必要だとGeminiが判断した場合、応答として、関数呼び出しが返ってきます。
[3] Applicationは、関数呼び出しを受け取ったら、関数を実行します。
[4] 関数の実行結果をGeminiに送ります。
[5] 複数の関数が必要な場合は[2]~[4]を繰り返し、実行の必要がなくなったところで最終結果が返ってきます。

関数の定義

現在時刻

「今日」や「明日」といった指定に応えるため、現在時刻を取得する関数を定義します。
パラメータはなく、アプリサーバの時刻をそのまま返す実装とします。

まず、Geminiに渡す関数定義を用意します。
descriptionに指定した説明をもとに、Geminiが関数を使用してくれます。呼び出しに関してパラメータは何も渡しませんが、parametersにはプロパティ無しのオブジェクトを定義しておきます。

func_description_time = FunctionDeclaration(
    name='time',
    description="現在時刻を返します。",
    parameters={
        "type": "object",
        "properties": {}
    },
)

次に、関数の実体を定義します。ここでは、アプリケーションサーバの時刻を成型して返すだけの処理とします。
実際にはtimezone等を考慮して返してください。

def time(args):
    return Part.from_function_response(
        name='time',
        response={
            "time": datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
        }
    )

スケジュール検索

スケジュール検索APIを呼び出す関数を用意します。今回は日付の範囲のみで抽出するようにしました。

関数の定義は次のようになります。
開始日付と終了日付を必須パラメータとして定義しています。
パラメータのdescriptionに設定例等を書いておくと、Geminiによってパラメータが生成されます。

func_description_schedule_get = FunctionDeclaration(
    name='schedule_get',
    description="Outlookの予定表を検索します。",
    parameters={
        "type": "object",
        "properties": {
            "start": {
                "type": "string",
                "format": "date-time",
                "description": "検索範囲の開始日時を設定してください。例)2023-01-01T10:00:00"
            },
            "end": {
                "type": "string",
                "format": "date-time",
                "description": "検索範囲の終了日付を設定してください。例)2023-01-01T10:00:00"
            }
        },
        "required": [
            "start",
            "end"
        ]
    },
)

実装部分は次のようになります。
この例はMicrosoft Graph APIを使用してoutlook予定表を取得しています。実際に呼び出すAPIや認証は、それぞれの環境に応じて実装してください。
APIの結果をそのままGeminiに戻すと情報が多すぎるため、必要な項目のみにして、function_responseを作成しています。

def schedule_get(args):
    # URL作成
    url = GRAPH_ENDPOINT + \
        f"/me/calendar/calendarView?startDateTime={args.get('start')}%2B09:00&endDateTime={args.get('end')}%2B09:00"

    # API呼び出し
    api_result = requests.get(
        url,
        headers={'Authorization': 'Bearer ' + access_token,
                 "Prefer": 'outlook.timezone="Asia/Tokyo"'},
        timeout=30,
    ).json()

    if api_result.get('error', False):
        return Part.from_function_response(
            name='schedule_get',
            response={"result": False, 'message': '検索できませんでした。'}
        )

    # 必要な情報のみ抽出
    events = []
    for event in api_result['value']:
        events.append({
            "subject": event.get('subject', ''),
            "start": event.get('start').get('dateTime'),
            "end": event.get('end').get('dateTime'),
            "isAllDay": event.get('isAllDay')
        })

    # Geminiに返すPartを生成
    return Part.from_function_response(
        name='schedule_get',
        response={"result": True, 'events': events}
    )

スケジュール登録

スケジュール登録も、同様に定義しておきます。

関数定義です。

func_description_schedule_post = FunctionDeclaration(
    name='schedule_post',
    description="予定をOutlookに登録します。",
    parameters={
        "type": "object",
        "properties": {
            "subject": {
                "type": "string",
                "description": "スケジュールのタイトルを設定してください。"
            },
            "body": {
                "type": "string",
                "description": "スケジュールの内容を設定してください。"
            },
            "start": {
                "type": "string",
                "format": "date-time",
                "description": "スケジュールの開始日時を設定してください。"
            },
            "end": {
                "type": "string",
                "format": "date-time",
                "description": "スケジュールの終了日時を設定してください。"
            }
        },
        "required": [
            "subject",
            "start",
            "end"
        ]
    }
)

実装部分です。
function_responseには、成功したかどうかだけ設定しています。

def schedule_post(args):
    # APIパラメータ作成
    event = {
        "subject": '[GeminiTest]' + args.get('subject'),
        "body": {
            "contentType": "HTML",
            "content": args.get('body', ''),
        },
        "start": {
            "dateTime": args.get('start'),
            "timeZone": "Asia/Tokyo"
        },
        "end": {
            "dateTime": args.get('end'),
            "timeZone": "Asia/Tokyo"
        },
    }
    # API呼び出し
    api_result = requests.post(
        GRAPH_ENDPOINT + '/me/events',
        headers={'Authorization': 'Bearer ' + access_token},
        json=event,
        timeout=30,
    ).json()

    if api_result.get('error', False):
        print(api_result)
        return Part.from_function_response(
            name='schedule_post',
            response={"result": False, 'message': '登録できませんでした。'}
        )

    # Geminiに返すPartを生成
    return Part.from_function_response(
        name='schedule_post',
        response={"result": True, 'message': '登録しました。'}
    )

Geminiと関数を使った会話をする

それでは、定義した関数を使って、Geminiと会話してみます。

実装

基本の実装は、gemini-proをマルチターンで呼び出す場合と同じです。
関数を使用させる場合は、send_message関数にtoolsパラメータとして関数の定義リストを渡してあげます。
Geminiが関数実行を要求する場合、func_callとして関数のnameとargsが渡されるので、それを参照して実際の関数を実行します。この例では、func_callが指定される間は実行と返答を繰り返し、func_callが指定されなくなったら最終回答としています。
※ 会話の内容や実装によって、予期せぬループになる可能性があります。関数の呼び出し回数制限等を実装してください。

from vertexai.preview.generative_models import GenerativeModel, FunctionDeclaration, Part, Tool

# 関数のdict
functions = {
    'time': time,
    'schedule_get': schedule_get,
    'schedule_post': schedule_post
}

def main():
    # modelの指定
    model = GenerativeModel(model_name="gemini-pro")
    # 関数定義の準備
    tool = Tool([
        func_description_time,
        func_description_schedule_get,
        func_description_schedule_post]
    )
    # チャット開始
    chat = model.start_chat()
    while True:
        # ユーザのinput
        print("[User]")
        text = input()
        res = chat.send_message(text, tools=[tool])

        # Geminiからfunction_callが指定されている間、関数を実行
        # ※ 予期せぬループを避けるため、回数制限等を実装してください。
        func_call = res.candidates[0].content.parts[0].function_call
        while func_call.name:
            # 関数を実行
            print("(FuncCall)")
            print(func_call.name)
            func = functions.get(func_call.name)
            if func:
                func_res = func(func_call.args)
            else:
                func_res = Part.from_function_response(name=func_call.name, response={
                                                       "result": False, 'message': 'functionが存在しません。'})
            
            # 実行結果を送信
            print("(FuncResponse)")
            print(func_res.function_response)
            res = chat.send_message(func_res, tools=[tool])
            
            # function_callの指定を確認
            func_call = res.candidates[0].content.parts[0].function_call

        # 最終回答
        print("[Gemini]")
        print(res.text)

実行

では、実行してみます。

まずはスケジュールの検索からです。「今日これからの予定を教えて」と送ってみました。
「今日」と指定しているので、「現在時刻」関数を呼び出して現在時刻を取得し、その後「スケジュール検索」関数を呼び出しています。
取得範囲が現在時刻から24時間になっているのは調整が必要ですが、必要に応じて2つのの関数が呼ばれているのがお分かりいただけるのではないでしょうか。
最終的に、結果を文章にして回答してくれています。

[User]
今日これからの予定を教えて
(FuncCall)
name: "time"
args {
}

(FuncResponse)
name: "time"
response {
  fields {
    key: "time"
    value {
      string_value: "2024-01-10T12:01:08"
    }
  }
}

(FuncCall)
name: "schedule_get"
args {
  fields {
    key: "start"
    value {
      string_value: "2024-01-10T12:01:08"
    }
  }
  fields {
    key: "end"
    value {
      string_value: "2024-01-11T12:01:08"
    }
  }
}

(FuncResponse)
name: "schedule_get"
response {
  fields {
    key: "result"
    value {
      bool_value: true
    }
  }
  fields {
    key: "events"
    value {
      list_value {
        values {
          struct_value {
            fields {
              key: "subject"
              value {
                string_value: "ランチミーティング"
              }
            }
            fields {
              key: "start"
              value {
                string_value: "2024-01-10T12:00:00.0000000"
              }
            }
            fields {
              key: "isAllDay"
              value {
                bool_value: false
              }
            }
            fields {
              key: "end"
              value {
                string_value: "2024-01-10T13:00:00.0000000"
              }
            }
          }
        }
        values {
          struct_value {
            fields {
              key: "subject"
              value {
                string_value: "打ち合わせ"
              }
            }
            fields {
              key: "start"
              value {
                string_value: "2024-01-10T14:00:00.0000000"
              }
            }
            fields {
              key: "isAllDay"
              value {
                bool_value: false
              }
            }
            fields {
              key: "end"
              value {
                string_value: "2024-01-10T14:30:00.0000000"
              }
            }
          }
        }
      }
    }
  }
}

[Gemini]
今日の予定はランチミーティングが12時から13時、打ち合わせが14時から14時半です。
[User]

次は登録をしてみます。「今日の17時から「会議」の予定を入れて」と送ってみます。
現在時刻確認後にスケジュール登録の関数が呼ばれています。
予定の内容や、終了時刻が勝手に設定されてしまっていますが、指定した内容に近い予定が、outlookに登録されました。

[User]
今日の17時から「会議」の予定を入れて
(FuncCall)
name: "time"
args {
}

(FuncResponse)
name: "time"
response {
  fields {
    key: "time"
    value {
      string_value: "2024-01-10T13:58:10"
    }
  }
}

(FuncCall)
name: "schedule_post"
args {
  fields {
    key: "subject"
    value {
      string_value: "会議"
    }
  }
  fields {
    key: "start"
    value {
      string_value: "2024-01-10T17:00:00"
    }
  }
  fields {
    key: "end"
    value {
      string_value: "2024-01-10T17:30:00"
    }
  }
  fields {
    key: "body"
    value {
      string_value: "会議しましょう"
    }
  }
}

(FuncResponse)
name: "schedule_post"
response {
  fields {
    key: "result"
    value {
      bool_value: true
    }
  }
  fields {
    key: "message"
    value {
      string_value: "登録しました。"
    }
  }
}

[Gemini]
はい、2024年1月10日の17時から17時30分まで会議という予定を登録しました。
ご確認をお願いします。
[User]

以上がそこそこ「良い」実行結果です。
今回はあまり調整もせず、シンプルに実装しただけなので、表現や言い回し等が変わると、思い通りに動いてくれないことが多くありました。

一つ、うまくいかなかった例もご紹介しておきます。
次の例では、予定の詳細を詳しく聞いてくれてはいるのですが、肝心の日付が間違っています。現在時刻の関数が呼ばれず、どこから持ってきたのかわからない日付が登録されました。
他にも、始めの質問の表現を変えると、いろいろと結果が変わりました。

[User]
明日の10時から会議の予定を追加して
[Gemini]
わかりました。会議のタイトルは何ですか?
[User]
定例会議
[Gemini]
わかりました。会議の開始時刻は明日10時からで、終了時刻は何時でしょうか?
[User]
11時
[Gemini]
ありがとうございます。会議の内容を教えていただけますか?
[User]
週次報告
[Gemini]
わかりました。それでは、明日10時から11時まで、「定例会議」というタイトルで、「週次報告」という内容の会議を予定に追加しておきます。よろしいでしょうか。
[User]
はい
(FuncCall)
name: "schedule_post"
args {
  fields {
    key: "subject"
    value {
      string_value: "\\u5bfe\\u5fdc\\u4f1a\\u8a08"
    }
  }
  fields {
    key: "start"
    value {
      string_value: "2023-08-09T10:00:00"
    }
  }
  fields {
    key: "end"
    value {
      string_value: "2023-08-09T11:00:00"
    }
  }
  fields {
    key: "body"
    value {
      string_value: "\\u9031\\u904e\\u5831\\u544a"
    }
  }
}

(FuncResponse)
name: "schedule_post"
response {
  fields {
    key: "result"
    value {
      bool_value: true
    }
  }
  fields {
    key: "message"
    value {
      string_value: "登録しました。"
    }
  }
}

[Gemini]
承知しました。予定を登録しました。
[User]

まとめ

ここまで、Geminiにスケジュールアシスタントをしてもらうための実装をご紹介してきました。
関数の定義を渡すことで、Geminiが必要なものを選択して、実行(の指示を)してくれます。「今日」と言われたから「現在時刻」が必要だと判断できるのはすごいことではないでしょうか。今回は、スケジュールアシスタントということで、スケジュールの検索と登録をしてもらいましたが、同様の方法でいろいろな処理、APIが呼び出せるようになります。AIと話すだけではなく、いろいろしてもらえるということは、今後の活用の幅が広がるはずです。
一方、精度はまだまだといった状態です。パラメータやdescriptionの調整でどこまでできるかは不明ですが、完璧に実行してもらうのは難しいと思われます。完璧でない部分をどうカバーするのか、アプリとしてどうまとめるのかが今後の課題になってくると感じました。
以上、未熟なアシスタントのご紹介でしたが、皆様のアイディアのきっかけになればうれしいです。