ローカルLLMのOllamaとPythonで作るFunction Calling機能の実装方法2

AI

前回は天気情報を取得する際は固定値で返すようにしていたため、外部のAPIを使用して天気情報を得るようにする。

前回記事 

はじめに

本記事では、OllamaのFunction Calling機能とOpen-Meteo APIを組み合わせた、実践的な天気予報システムの実装方法を解説します。無料で利用できるOpen-Meteo APIを活用し、リアルタイムな天気情報を取得する。

システム概要

アーキテクチャ

以下のシーケンス図は、システム全体の処理フローを示す。

実装の詳細解説

天気情報取得機能(get_weather関数)

get_weather関数は、Open-Meteo APIを使用して天気情報を取得します。

実装のポイント:

  • 緯度経度による位置指定
  • 天気コードの日本語への変換
  • エラーハンドリングの実装
def get_weather(location: str) -> str:
    """
    Open-Meteo APIを用いて、指定された場所の天気と温度を返す関数です。
    """
    try:
        # Open-Meteo APIは都市名での直接検索をサポートしていないため、
        # 緯度・経度を取得する別のAPI (例: Nominatim) を使用するか、
        # または固定の緯度・経度を使用する必要があります。
        # ここでは簡略化のため、東京の緯度・経度を固定で使用します。
        latitude = 35.6895
        longitude = 139.6917

        params = {
            "latitude": latitude,
            "longitude": longitude,
            "current": ["temperature_2m", "weather_code"],
            "hourly": "temperature_2m",
            "timezone": "auto",
        }
        response = requests.get(OPEN_METEO_URL, params=params)
        response.raise_for_status()  # HTTPエラーをチェック
        data = response.json()

        temperature = data["current"]["temperature_2m"]
        weather_code = data["current"]["weather_code"]

        # 天気コードから天気の説明を取得する関数
        weather_description = get_weather_description(weather_code)

        return f"{location} の天気は {weather_description} で、温度は {temperature} 度です。"
    except requests.exceptions.RequestException as e:
        return f"エラーが発生しました: {e}"


def get_weather_description(weather_code: int) -> str:
    """
    天気コードに基づいて天気の説明を返します。
    """
    weather_descriptions = {
        0: "晴れ",
        1: "主に晴れ",
        2: "部分的に曇り",
        3: "曇り",
        45: "霧",
        48: "凍結霧",
        51: "小雨",
        53: "適度な雨",
        55: "激しい雨",
        56: "凍雨",
        57: "激しい凍雨",
        61: "弱い雨",
        63: "適度な雨",
        65: "強い雨",
        66: "軽い凍雨",
        67: "重い凍雨",
        71: "わずかな積雪",
        73: "適度な積雪",
        75: "激しい積雪",
        77: "雪の粒子",
        80: "弱い雨",
        81: "適度な雨",
        82: "激しい雨",
        85: "弱い雪",
        86: "激しい雪",
        95: "雷雨",
        96: "ひょうを伴う雷雨",
        99: "大ひょうを伴う雷雨",
    }
    return weather_descriptions.get(weather_code, "不明")

3.2 システムプロンプトの設計(create_payload関数)

Function Callingを効果的に活用するためのプロンプトを設計します。

重要なポイント:

  • 明確な役割の定義
  • JSON形式の指定
  • エラー時の対応指示
def create_payload() -> dict:
    """
    システムプロンプトとユーザーメッセージを含むリクエストペイロードを生成します。
    ※システムプロンプトで、出力は必ず JSON 形式の function_call として返すように明示しています。
    """
    messages = [
        {
            "role": "system",
            "content": (
                "あなたは天気情報を提供するエージェントです。"
                "ユーザーからの質問に対しては、必ず下記の形式に沿った JSON 形式の function_call として回答してください。\n\n"
                "例:\n"
                "{\n"
                '  "function_call": {\n'
                '    "name": "get_weather",\n'
                '    "arguments": "{\\"location\\": \\"東京\\"}"\n'
                "  }\n"
                "}\n\n"
                "絶対に他の形式では回答しないでください。"
            ),
        },
        {"role": "user", "content": "東京の天気と温度を教えてください。"},
    ]

    functions = [
        {
            "name": "get_weather",
            "description": "指定された場所の天気と温度を返す関数です。",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "天気情報を取得する場所の名前",
                    }
                },
                "required": ["location"],
            },
        }
    ]

    payload = {
        "model": MODEL_NAME,
        "messages": messages,
        "functions": functions,
        # 明示的に関数呼び出しを指定
        "function_call": {"name": "get_weather"},
    }
    return payload

APIとの連携方法

Open-Meteo APIの概要と特徴

天気取得のためにOpen‐MetroのAPIを使用します。特徴は以下の通り。

  • 無料で利用可能
  • API制限が緩やか
  • 豊富な気象データ
  • APIキー不要

Open-Meteo APIの基本仕様

エンドポイント

https://api.open-meteo.com/v1/forecast

パラメータ

パラメータ説明
latitude緯度35.6895 (東京)
longitude経度139.6917 (東京)
current現在の気象データ[“temperature_2m”, “weather_code”]
hourly1時間ごとの予報[“temperature_2m”]
daily日単位の予報[“temperature_2m_max”, “temperature_2m_min”]
timezoneタイムゾーン"auto" または "Asia/Tokyo"

レスポンス例

{
  "latitude": 35.6895,
  "longitude": 139.6917,
  "timezone": "Asia/Tokyo",
  "current": {
    "time": "2024-03-20T12:00",
    "temperature_2m": 22.5,
    "weather_code": 1
  }
}

天気コードの解析と変換

Open-Meteo APIは国際的な気象コードを使用しています。日本語での表示のため、以下のように変換を行います。

    weather_descriptions = {
        0: "晴れ",
        1: "主に晴れ",
        2: "部分的に曇り",
        3: "曇り",
        45: "霧",
        48: "凍結霧",
        51: "小雨",
        53: "適度な雨",
        55: "激しい雨",
        56: "凍雨",
        57: "激しい凍雨",
        61: "弱い雨",
        63: "適度な雨",
        65: "強い雨",
        66: "軽い凍雨",
        67: "重い凍雨",
        71: "わずかな積雪",
        73: "適度な積雪",
        75: "激しい積雪",
        77: "雪の粒子",
        80: "弱い雨",
        81: "適度な雨",
        82: "激しい雨",
        85: "弱い雪",
        86: "激しい雪",
        95: "雷雨",
        96: "ひょうを伴う雷雨",
        99: "大ひょうを伴う雷雨",
    }

APIの利用上の注意点

  • 位置情報の指定
    •   都市名での直接検索は非対応
    •   緯度経度による指定が必要
    •   必要に応じてジオコーディングサービスとの併用を推奨
  • データの更新頻度
    • 現在の気象データ:10分ごとに更新
    • 予報データ:6時間ごとに更新
  • エラーハンドリング
    • 無効な座標の場合:400エラー
    • サーバー負荷時:429エラー(レート制限)
    • 推奨:エラーコードに応じた適切な処理の実装
  • レート制限
    • 1日あたり10,000リクエストまで無料
    • IP単位でのカウント
    • 商用利用の場合は別途ライセンスが必要
  • パフォーマンス最適化
params = {
    "latitude": latitude,
    "longitude": longitude,
    "current": ["temperature_2m", "weather_code"],  # 必要な項目のみ指定
    "timezone": "auto",
}

エラーハンドリング

例外処理

  • APIリクエストエラー
  • JSONパースエラー
  • 関数引数のバリデーション
  • 未定義関数の呼び出し検出

エラー発生時のフォールバック

try:
    response = requests.get(OPEN_METEO_URL, params=params)
    response.raise_for_status()
except requests.exceptions.RequestException as e:
    return f"エラーが発生しました: {e}"
  

実行結果と動作確認

LLM からの function_call: get_weather({'location': 'Tokyo'})
実行結果: Tokyo の天気は 晴れ で、温度は 11.6 度です。

まとめ

OllamaのFunction CallingとOpen-Meteo APIを組み合わせた実践的な実装方法を紹介しました。この実装を基に他の外部APIとの連携を図りたいと思います。次は緯度経度を取得したいと思います。

コード全文

import json
import re
import requests
import os

# 定数設定
OLLAMA_URL = "http://localhost:11434/v1/chat/completions"
MODEL_NAME = "mistral-small"
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"


def get_weather(location: str) -> str:
    """
    Open-Meteo APIを用いて、指定された場所の天気と温度を返す関数です。
    """
    try:
        # Open-Meteo APIは都市名での直接検索をサポートしていないため、
        # 緯度・経度を取得する別のAPI (例: Nominatim) を使用するか、
        # または固定の緯度・経度を使用する必要があります。
        # ここでは簡略化のため、東京の緯度・経度を固定で使用します。
        latitude = 35.6895
        longitude = 139.6917

        params = {
            "latitude": latitude,
            "longitude": longitude,
            "current": ["temperature_2m", "weather_code"],
            "hourly": "temperature_2m",
            "timezone": "auto",
        }
        response = requests.get(OPEN_METEO_URL, params=params)
        response.raise_for_status()  # HTTPエラーをチェック
        data = response.json()

        temperature = data["current"]["temperature_2m"]
        weather_code = data["current"]["weather_code"]

        # 天気コードから天気の説明を取得する関数
        weather_description = get_weather_description(weather_code)

        return f"{location} の天気は {weather_description} で、温度は {temperature} 度です。"
    except requests.exceptions.RequestException as e:
        return f"エラーが発生しました: {e}"


def get_weather_description(weather_code: int) -> str:
    """
    天気コードに基づいて天気の説明を返します。
    """
    weather_descriptions = {
        0: "晴れ",
        1: "主に晴れ",
        2: "部分的に曇り",
        3: "曇り",
        45: "霧",
        48: "凍結霧",
        51: "小雨",
        53: "適度な雨",
        55: "激しい雨",
        56: "凍雨",
        57: "激しい凍雨",
        61: "弱い雨",
        63: "適度な雨",
        65: "強い雨",
        66: "軽い凍雨",
        67: "重い凍雨",
        71: "わずかな積雪",
        73: "適度な積雪",
        75: "激しい積雪",
        77: "雪の粒子",
        80: "弱い雨",
        81: "適度な雨",
        82: "激しい雨",
        85: "弱い雪",
        86: "激しい雪",
        95: "雷雨",
        96: "ひょうを伴う雷雨",
        99: "大ひょうを伴う雷雨",
    }
    return weather_descriptions.get(weather_code, "不明")


def create_payload() -> dict:
    """
    システムプロンプトとユーザーメッセージを含むリクエストペイロードを生成します。
    ※システムプロンプトで、出力は必ず JSON 形式の function_call として返すように明示しています。
    """
    messages = [
        {
            "role": "system",
            "content": (
                "あなたは天気情報を提供するエージェントです。"
                "ユーザーからの質問に対しては、必ず下記の形式に沿った JSON 形式の function_call として回答してください。\n\n"
                "例:\n"
                "{\n"
                '  "function_call": {\n'
                '    "name": "get_weather",\n'
                '    "arguments": "{\\"location\\": \\"東京\\"}"\n'
                "  }\n"
                "}\n\n"
                "絶対に他の形式では回答しないでください。"
            ),
        },
        {"role": "user", "content": "東京の天気と温度を教えてください。"},
    ]

    functions = [
        {
            "name": "get_weather",
            "description": "指定された場所の天気と温度を返す関数です。",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "天気情報を取得する場所の名前",
                    }
                },
                "required": ["location"],
            },
        }
    ]

    payload = {
        "model": MODEL_NAME,
        "messages": messages,
        "functions": functions,
        # 明示的に関数呼び出しを指定
        "function_call": {"name": "get_weather"},
    }
    return payload


def send_request(payload: dict) -> dict:
    """
    Ollama サーバーへリクエストを送信し、JSON レスポンスを返します。
    """
    response = requests.post(OLLAMA_URL, json=payload)
    response.raise_for_status()
    return response.json()


def extract_function_call_from_content(content: str) -> dict:
    """
    メッセージの content 内に含まれるコードブロックから JSON を抽出し、
    その中に含まれる function_call 情報を返します。
    """
    # 正規表現で ```json ... ``` ブロックを抽出
    pattern = r"```json\s*(\{.*?\})\s*```"
    match = re.search(pattern, content, re.DOTALL)
    if match:
        json_str = match.group(1)
        try:
            parsed = json.loads(json_str)
            if "function_call" in parsed:
                return parsed["function_call"]
        except Exception as e:
            print("コードブロック内の JSON パースに失敗:", e)
    else:
        # もしコードブロックがない場合、内容全体が JSON であれば試す
        try:
            parsed = json.loads(content)
            if "function_call" in parsed:
                return parsed["function_call"]
        except Exception:
            pass
    return {}


def handle_response(result: dict):
    """
    LLM のレスポンスから function_call 情報を抽出し、
    対応するローカル関数を実行する処理を行います。
    """
    print("LLM からの応答:")
    print(json.dumps(result, indent=2, ensure_ascii=False))

    choices = result.get("choices", [])
    if not choices:
        print("応答形式が不正です。")
        return

    message = choices[0].get("message", {})
    # まず、直接 function_call フィールドがあるか確認
    function_call = message.get("function_call")
    if not function_call:
        # なければ、content 内のコードブロックから抽出を試みる
        content = message.get("content", "")
        function_call = extract_function_call_from_content(content)

    if function_call:
        function_name = function_call.get("name")
        raw_arguments = function_call.get("arguments", "{}")
        # 引数が余分な引用符で囲まれている場合、その除去を試みる
        if raw_arguments.startswith('"') and raw_arguments.endswith('"'):
            raw_arguments = raw_arguments[1:-1]

        try:
            arguments = json.loads(raw_arguments)
        except Exception as e:
            print("関数引数のパースに失敗:", e)
            arguments = {}

        print(f"\nLLM からの function_call: {function_name}({arguments})")
        if function_name == "get_weather":
            result_value = get_weather(**arguments)
            print("実行結果:", result_value)
        else:
            print(f"定義されていない関数が呼ばれました: {function_name}")
    else:
        print("LLM の応答内に function_call が見つかりませんでした。")
        print("返答内容:", message.get("content"))


def main():
    payload = create_payload()
    try:
        result = send_request(payload)
        handle_response(result)
    except Exception as e:
        print("リクエスト中にエラーが発生しました:", e)


if __name__ == "__main__":
    main()

コメント

タイトルとURLをコピーしました