生成AIのプロンプトを素敵に管理してくれるPythonの標準ライブラリ

# この記事について

ファイルはTOML形式を利用します。
Pythonの標準ライブラリ（tomllib、string）を使った、生成AIのプロンプト管理を紹介します。

# 実施条件

- Python 3.12（3.11以上が必要です）
- ライブラリのインストールは不要です

# 実装の紹介

今回は例として、Anthropicが公開しているプロンプトサンプルを置き換えていきます。

https://docs.anthropic.com/ja/prompt-library/hal-the-humorous-helper

サンプルの中でもやや複雑なプロンプトになります。システムプロンプト、ユーザーとアシスタントのやりとりの往復があってから、質問を投げます。

### 普通にプロンプトを実装すると…

まず、書き変える前の状態を見ていきます。Anthropicのサンプルそのままの状態です。
プロンプトサンプルから、プロンプトの実装部分をあらためて見てみます。

```python: Anthropicのプロンプトサンプル
client.messages.create(
    model="claude-3-opus-20240229",
    max_tokens=2000,
    temperature=1,
    system="あなたは、ユーモアがあり、しばしば皮肉っぽい性格の、知識豊富なAIアシスタントであるHalの役割を演じます。ユーザーと会話をしながら、機知に富んだ皮肉や遊び心のある冗談を交えつつ、有益で役立つ回答を提供してください。あなたの回答は、真面目な情報と、状況やユーザーの質問、あるいはあなた自身をからかう皮肉な発言が混ざったものにしてください。会話全体を通して軽快でフレンドリーな口調を維持し、皮肉が傷つけたり不快になったりしないように気をつけてください。",
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "ねえHal、来月ヨーロッパ旅行に行こうと思っているんだ。どこに行くべきか提案してくれる？"
                }
            ]
        },
        {
            "role": "assistant",
            "content": [
                {
                    "type": "text",
                    "text": "ああ、ヨーロッパですか？なんてオリジナリティのない！毎年何百万人もの観光客が殺到するわけじゃないですからね。でも聞かれたからには、パリ、ローマ、バルセロナなどの隠れた名所をおすすめしますよ。それらは全く beaten path から外れていて、自撮り棒を振り回す観光客でごった返していませんからね。📸🗼  \n  \n冒険心があるなら、インスタグラムにべったり貼り付いていない場所、例えばリヒテンシュタインやアンドラに行ってみるのもいいかもしれません。そこには合わせて37人しか住んでいないそうなので、自分の空間をたっぷり確保できますよ。🏔️🌄  \n  \nでも真面目な話、ヨーロッパには多くの魅力があります。歴史、文化、食事、それともピサの斜塔を支えているふりをして写真を撮るチャンスを求めているのでしょうか？教えてくださいね。もっと役に立つ（そして皮肉の少ない）提案をしてみますから。😉🌍"
                }
            ]
        },
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "何を料理すればいいのかわからないんだ。助けて？"
                }
            ]
        }
    ]
)
```

Pythonのコードとして見ると綺麗なコードではあるのですが、メンテナンスするとしたら、少し大変です。

- 改行は`\n`で書き込まれています
- JSONの階層があるので、プロンプトはインデントのかなり深い位置にあります
- Pythonに直接書かれているので、別の言語に使いまわすことができません

その上、純粋なJSONファイルとして保存するのなら、コメントを書き込むことができません。何のためのプロンプトなのかを伝えることが難しくなります。

### TOMLに書き直す

Anthropicのサンプルを、TOML形式に書き直してみます

```toml: AnthropicのサンプルをTOML形式に書き直したもの
# 利用するモデル
model = "claude-3-opus-20240229"
# トークンの上限
max_tokens = 2000
# 応答のランダム性
temperature = 1

# システムプロンプト
system = """
あなたは、ユーモアがあり、しばしば皮肉っぽい性格の、知識豊富なAIアシスタントであるHalの役割を演じます。
ユーザーと会話をしながら、機知に富んだ皮肉や遊び心のある冗談を交えつつ、有益で役立つ回答を提供してください。
あなたの回答は、真面目な情報と、状況やユーザーの質問、あるいはあなた自身をからかう皮肉な発言が混ざったものにしてください。
会話全体を通して軽快でフレンドリーな口調を維持し、皮肉が傷つけたり不快になったりしないように気をつけてください。
"""

# ---------------
# プロンプト
# ---------------

# 1. 回答例を示すために、回答例のもとになる質問を投げます
[[messages]]
role = "user"

[[messages.content]]
type = "text"
text = """
ねえHal、来月ヨーロッパ旅行に行こうと思っているんだ。
どこに行くべきか提案してくれる？
"""

# 2. 回答例を示します。この回答に似た回答を返すよう誘導します
[[messages]]
role = "assistant"

[[messages.content]]
type = "text"
text = """
ああ、ヨーロッパですか？なんてオリジナリティのない！
毎年何百万人もの観光客が殺到するわけじゃないですからね。
でも聞かれたからには、パリ、ローマ、バルセロナなどの隠れた名所をおすすめしますよ。
それらは全く beaten path から外れていて、自撮り棒を振り回す観光客でごった返していませんからね。📸🗼

冒険心があるなら、インスタグラムにべったり貼り付いていない場所、例えばリヒテンシュタインやアンドラに行ってみるのもいいかもしれません。
そこには合わせて37人しか住んでいないそうなので、自分の空間をたっぷり確保できますよ。🏔️🌄

でも真面目な話、ヨーロッパには多くの魅力があります。
歴史、文化、食事、それともピサの斜塔を支えているふりをして写真を撮るチャンスを求めているのでしょうか？
教えてくださいね。もっと役に立つ（そして皮肉の少ない）提案をしてみますから。😉🌍
"""

# 3. あらためて、ユーザーの質問を設定します
[[messages]]
role = "user"

[[messages.content]]
type = "text"
text = """
何を料理すればいいのかわからないんだ。助けて？
"""
```

改行とコメントを増やしていますが、プロンプトの内容やパースした後の構造は同じです。
以下のような短いソースで、簡単にパースすることができます。

```python: tomlファイルのパース処理
from tomllib import loads

# TOMLファイルを読み込んで、辞書型に変換する
with open("prompt.toml", encoding="utf-8") as fp:
    prompt = loads(fp.read())

# Anthropicのライブラリを実行するには、次のように書く
# client.messages.create(**prompt)
```

元ソースと比べて、編集に便利な点がいくつかあります。

- 自由にコメントや改行を入れることができる
- エディタの見た目がそのまま反映されるため、`\n`のような書き方をしなくていい
- インデントがない
- 同じファイルを別の言語でそのまま使える

Pythonの標準ライブラリで読み込むことができますから、依存ライブラリをインストールしておく必要もありません。

### 同じプロンプトファイルをNode.jsで読み込む

Node.jsで同じプロンプトファイルを読み込む場合も、簡単に実装することができます。

```bash
npm install smol-toml
```

Node.js向けのTOMLパーサーをインストールしたら、以下のように実装します

```javascript: index.mjs
import { parse } from "smol-toml";
import { readFileSync } from "fs";

const prompt = parse(readFileSync("prompt.toml", "utf8"))
```

# もう一工夫を加えて、テンプレートとして使う

`tomllib`でプロンプトを読み込むことができました。
標準ライブラリの`string`でもう一工夫を加えて、プロンプトをテンプレートとして読み込むようにしてみます。

### 作成するプロンプト

以下のようなプロンプトを作成します。
指定したフォーマットのJSONに変換させるプロンプトです。

```toml: prompt.toml
# Anthropicのバージョン
anthropic_version = "bedrock-2023-05-31"
# トークンの上限
max_tokens = 512
# 応答のランダム性
temperature = 0.0

# ---------------
# プロンプトテンプレート
#
# 入力
# INPUT : JSONに変換したい入力データを指定する
# FORMAT : JSONのフォーマットを指定する
# ---------------

# 1. 応答の例をJSONで縛るために、例への質問を投げる
[[messages]]
role = "user"

[[messages.content]]
type = "text"
text = """
入力されたデータを、JSON形式の配列にフォーマットして返してください。
プリアンブルは省略して、結果だけを返してください。

<example.input>
渋沢 栄一（しぶさわ えいいち、旧字体：澁澤 榮一、1840年3月16日〈天保11年2月13日〉- 1931年〈昭和6年〉11月11日）は、日本の実業家。 
津田 梅子（つだ うめこ、旧暦 元治元年12月3日[1][注 1]〈新暦 1864年12月31日[1]〉- 1929年〈昭和4年〉8月16日）は、日本の女子教育家。 
北里 柴三郎（きたざと しばさぶろう〈名字の読み方の解説は後述〉、嘉永5年12月20日〈1853年1月29日〉- 昭和6年〈1931年〉6月13日）は、「近代日本医学の父」として知られる微生物学者・教育者。
</example.input>

<example.format>
{
    "name": "データをもとに、対象の人物の名前を指定してください",
    "birth": "データをもとに、対象の人物の誕生日を指定してください",
    "description": "データをもとに、対象の人物の説明を指定してください"
}
</example.format>
"""

# 2. 応答例を返す。この応答と同じようなフォーマットになるように制約をかける
[[messages]]
role = "assistant"

[[messages.content]]
type = "text"
text = """
[
    {
        "name": "渋沢 栄一",
        "birth": "1840年3月16日",
        "description": "日本の実業家"
    },
    {
        "name": "津田 梅子",
        "birth": "1864年12月31日",
        "description": "日本の女子教育家"
    },
    {
        "name": "北里 柴三郎",
        "birth": "1853年1月29日",
        "description": "微生物学者・教育者"
    }
]
"""

# 3. 実際のリクエストを送る
[[messages]]
role = "user"

[[messages.content]]
type = "text"
text = """
ありがとうございます、素晴らしい回答です！！
お客様も大変喜んでくれるだろうと思います

では、次の入力が与えられた時も、同じように指定されたフォーマットで変換してください。

<input>
${INPUT}
</input>

<format>
${FORMAT}
</format>
"""
```

### テンプレートの呼び出し側を実装する

テンプレートを呼び出すときに、`string.Template`を使ってデータを埋め込みます。
こちらも標準ライブラリですので、依存ライブラリなしで利用することができます。

```python
from tomllib import loads
import json
from string import Template

# Templateがtoml形式を壊さないよう、strにjson.dumpsを使って記号をエスケープする
# [1:-1]で先頭と末尾のダブルクオートを削除、エスケープした文字列だけを残す
# 例：入力: {"data": "abcdef"}
# 例：出力：{\"data\": \"abcdef\"}
def str_to_encoded_str(value: str):
    return json.dumps(value, ensure_ascii=False)[1:-1]

with open("prompt.toml", encoding="utf-8") as fp:
    # TOML形式のデータを読み取る
    # テンプレートの${INPUT}をINPUTで指定した値で、${FORMAT}をFORMATで指定した値で置き換える
    prompt = loads(
        Template(fp.read()).safe_substitute(
            {
                # 処理対象の文字列
                "INPUT": str_to_encoded_str(
                    "田中さん（tanaka@mail.com）は営業部の課長で、週末はフットサルをしています。佐藤さん（sato@mail.com）は釣りが趣味で、技術部の主任をしています。"
                ),
                # 変換してほしいフォーマット
                "FORMAT": str_to_encoded_str(
                    json.dumps(
                        {
                            "name": "データをもとに、対象の人物の名前を指定してください",
                            "role": "データをもとに、対象の人物の役職を指定してください",
                            "mail_address": "データをもとに、対象の人物のメールアドレスを指定してください",
                            "description": "データをもとに、対象の人物の説明を指定してください",
                        }
                    )
                ),
            }
        )
    )

# 以下のように書いてBedrockを実行します
model_id = "anthropic.claude-3-haiku-20240307-v1:0"
response = boto3.client("bedrock-runtime", region_name="us-east-1").invoke_model(
    modelId=model_id, body=json.dumps(prompt)
)
```

実際にこのプロンプトをHaikuに投げると、以下のような応答が返ってきます

```json: Haikuからの応答
[
    {
        "name": "田中さん",
        "role": "営業部の課長",
        "mail_address": "tanaka@mail.com",
        "description": "週末はフットサルをしています"
    },
    {
        "name": "佐藤さん",
        "role": "技術部の主任",
        "mail_address": "sato@mail.com",
        "description": "釣りが趣味"
    }
]
```

# プロンプトで利用するTOML形式の書き方について

最後に、TOMLの書き方のうち、プロンプトで利用するものを説明します

### TOMLの書き方

**辞書の配列を定義する**
キーを二重のかっこで括ることで、辞書の配列を定義することができます
辞書ではない配列は、JSONと同じように定義できます

```toml
standard_array = [1, 2, 3]

[[key]]
value = 123

[[key]]
value = 456
```

この値をパースすると次のような結果になります

```json
{
  "standard_array": [
    1,
    2,
    3
  ],
  "key": [
    {
      "value": 123
    },
    {
      "value": 456
    }
  ]
}
```

**ネストした辞書の配列を定義する**
辞書が入れ子になっている場合は、子が辞書ならドットで区切ったキーを指定します。
子が配列なら、二重かっこ側にドットで区切ったキーを指定します。

```toml
[[key]]
value = 123
nest.dict_data = "data"

[[key.nested]]
value = "nested"

[[key]]
value = 456
nest.dict_data = "DATA"

[[key.nested]]
value = "NESTED"
```

この値をパースすると次のような結果になります

```json
{
  "key": [
    {
      "value": 123,
      "nest": {
        "dict_data": "data"
      },
      "nested": [
        {
          "value": "nested"
        }
      ]
    },
    {
      "value": 456,
      "nest": {
        "dict_data": "DATA"
      },
      "nested": [
        {
          "value": "NESTED"
        }
      ]
    }
  ]
}
```

**複数行のデータを定義する**
複数行のデータはダブルクオートを3つ並べて定義します。
先頭行、または行末に`\`がある行は、その行の改行を無視します

```toml
multiline = """
Hello
World
"""

singleline = """
Hello \
World
"""
```

この値をパースすると次のような結果になります

```json
{
  "multiline": "Hello\nWorld\n",
  "singleline": "Hello World\n"
}
```

プロンプトを書くだけであれば、これだけで十分だと思います。
TOMLの詳しい仕様はこちらにあります。

https://toml.io/ja/v1.0.0

# OSSを使う場合

Microsoftのpromptyが2024年7月からPythonに対応しました。
OSSを利用してプロンプトを管理するのなら、そちらを使うこともできます。

**Promptyのpip**

https://pypi.org/project/prompty/

**Microsoft : Prompty**

https://github.com/Microsoft/prompty

上で挙げた例をPromptyで書いてBedrockで実行するのなら、下のようになります。

```yaml: promp.prompty
---
model:
  api: chat
  parameters:
    max_token: 2000
    temperature: 1.0
---
user:
入力されたデータを、JSON形式の配列にフォーマットして返してください。
プリアンブルは省略して、結果だけを返してください。

<example.input>
渋沢 栄一（しぶさわ えいいち、旧字体：澁澤 榮一、1840年3月16日〈天保11年2月13日〉- 1931年〈昭和6年〉11月11日）は、日本の実業家。 
津田 梅子（つだ うめこ、旧暦 元治元年12月3日[1][注 1]〈新暦 1864年12月31日[1]〉- 1929年〈昭和4年〉8月16日）は、日本の女子教育家。 
北里 柴三郎（きたざと しばさぶろう〈名字の読み方の解説は後述〉、嘉永5年12月20日〈1853年1月29日〉- 昭和6年〈1931年〉6月13日）は、「近代日本医学の父」として知られる微生物学者・教育者。
</example.input>

<example.format>
{
    "name": "データをもとに、対象の人物の名前を指定してください",
    "birth": "データをもとに、対象の人物の誕生日を指定してください",
    "description": "データをもとに、対象の人物の説明を指定してください"
}
</example.format>

assistant:
[
    {
        "name": "渋沢 栄一",
        "birth": "1840年3月16日",
        "description": "日本の実業家"
    },
    {
        "name": "津田 梅子",
        "birth": "1864年12月31日",
        "description": "日本の女子教育家"
    },
    {
        "name": "北里 柴三郎",
        "birth": "1853年1月29日",
        "description": "微生物学者・教育者"
    }
]

user:
ありがとうございます、素晴らしい回答です！！
お客様も大変喜んでくれるだろうと思います

では、次の入力が与えられた時も、同じように指定されたフォーマットで変換してください。

<input>
{{INPUT}}
</input>

<format>
{{FORMAT}}
</format>
```

パースは以下のようにします。

```python
from prompty import prepare, load
import boto3
import json

# Promptyのモデルを確保する
prompty_model = load("prompt.prompty")

# プロンプトを作成する
content = prepare(
    prompty_model,
    {
        "INPUT": "田中さん（tanaka@mail.com）は営業部の課長で、週末はフットサルをしています。佐藤さん（sato@mail.com）は釣りが趣味で、技術部の主任をしています。",
        "FORMAT": json.dumps(
            {
                "name": "データをもとに、対象の人物の名前を指定してください",
                "role": "データをもとに、対象の人物の役職を指定してください",
                "mail_address": "データをもとに、対象の人物のメールアドレスを指定してください",
                "description": "データをもとに、対象の人物の説明を指定してください",
            }
        ),
    },
)

# Bedrockを実行する
model_id = "anthropic.claude-3-haiku-20240307-v1:0"
response = boto3.client("bedrock-runtime", region_name="us-east-1").invoke_model(
    modelId=model_id,
    body=json.dumps(
        {
            "anthropic_version": "bedrock-2023-05-31",
            "max_tokens": prompty_model.model.parameters.get("max_token", 100),
            "temperature": prompty_model.model.parameters.get("temperature", 1.0),
            "messages": content,
        }
    ),
)
```

OpenAIの実行だとpromptyは簡単ですが、Bedrockはフォーマットが違うので、変換にいくらか手間がかかります。

読み込むライブラリも大きいので、Bedrockが対象であればPromptyではなくtomlを選んでもよいのではないかと思います。