kun432's blog

Alexaなどスマートスピーカーの話題中心に、Voiceflowの日本語情報を発信してます。たまにAWSやkubernetesなど。

〜スマートスピーカーやVoiceflowの記事は右メニューのカテゴリからどうぞ。〜

Voiceflow Dialog Management APIのStateless APIを試してみた

f:id:kun432:20210313005910p:plain

過去の記事でVoiceflow Dialog Management APIをいろいろ試していましたが、全部State APIを使っていました。

Dialog Management APIはState APIだけではなく、もう一つ、Stateless APIというのがあります。今回はこれを試してみました。

目次

Stateless APIとは?

State APIは、エンドポイントURLにユーザIDを含めることで、永続セッションの情報をVoiceflow側に持たせることができるというものでした。これに対して、Stateless APIは、

  • ユーザIDは存在しない
  • 永続情報(state)を手元で管理し、Stateless APIにリクエストを送る際に常に送信する

というものです。めんどくさいユーザごとのセッション管理をやってくれるのならStateless APIのほうが良いように思えますが、Stateless APIのメリットは以下です。

  • ユーザIDが存在しないような場合でも使える
    • ログインの仕組みがない、Webチャットボットなど
  • セキュアな情報を手元で管理できる
    • クラウドに上げたくないようなケースなど

試してみる

では早速試してみましょう。サンプルはこういう物を作りました。

f:id:kun432:20220201020356p:plain

単純にユーザの名前を聞いて答えるだけのシンプルなものです。

f:id:kun432:20220201020456p:plain

ただし2回目はユーザ名を覚えているのでこういうふうになるイメージです。

f:id:kun432:20220201020503p:plain

cURLで試してみる

State APIには2つのエンドポイントがあります。

  • default state(/interact/state)
    • 初期状態のStateが返される
  • interact
    • State APIと同じく、発話等の内容を含んだtraceが返されるとともに、その結果のStateが返される。

実際にはdefault stateはなくても動くようです(interactにStateをつけなければ結局同じ感じになると思います)。まずは試してみましょう。

最初にAPIキーを環境変数にセットしておきます。

$ export API_KEY="VF.XXXXXXXXXXXXXXXXXX"

まずはstateなしで何か発話を送ってみましょう。発話等ユーザからの入力はactionで送ります。以下のようなJSONを送信してみます。

{
  "action": {
    "type": "text",
    "payload": "hello"
   }
}

こんな感じでヒアドキュメントで渡してあげるとよいですね。

$ curl -s -X POST "https://general-runtime.voiceflow.com/interact" -H "Authorization: $API_KEY" -H 'Content-Type: application/json' -d @- <<'EOF' | jq .
> {
>   "action": {
>     "type": "text",
>     "payload": "hello"
>    }
> }
> EOF

こんな感じJSONが返ってきます。

{
  "request": {
    "type": "intent",
    "payload": {
      "intent": {
        "name": "capture_tmp_user_name_pmdkppeb"
      },
      "query": "",
      "entities": []
    },
    "ELICIT": true
  },
  "state": {
    "stack": [
      {
        "nodeID": null,
        "programID": "61f664249192710006fe1917",
        "storage": {},
        "commands": [],
        "variables": {}
      },
      {
        "nodeID": "61f664438e7a1488ab44a345",
        "programID": "61f664249192710006fe1918",
        "storage": {
          "output": [
            {
              "children": [
                {
                  "text": "Hi, what's your name?"
                }
              ]
            }
          ]
        },
        "commands": [],
        "variables": {}
      }
    ],
    "storage": {
      "dm": {
        "intentRequest": {
          "type": "intent",
          "payload": {
            "intent": {
              "name": "capture_tmp_user_name_pmdkppeb"
            },
            "query": "",
            "entities": []
          },
          "ELICIT": true
        }
      }
    },
    "variables": {
      "tmp_user_name": 0,
      "user_name": 0,
      "intent_confidence": 94.95,
      "last_utterance": "hello",
      "timestamp": 1643723759
    }
  },
  "trace": [
    {
      "type": "text",
      "payload": {
        "slate": {
          "id": "5z53goa",
          "content": [
            {
              "children": [
                {
                  "text": "Hi, what's your name?"
                }
              ]
            }
          ]
        },
        "message": "Hi, what's your name?"
      }
    },
    {
      "type": "entity-filling",
      "payload": {
        "entityToFill": "tmp_user_name",
        "intent": {
          "type": "intent",
          "payload": {
            "intent": {
              "name": "capture_tmp_user_name_pmdkppeb"
            },
            "query": "",
            "entities": []
          },
          "ELICIT": true
        }
      }
    }
  ]
}

State APIと同じく、ボットからの発話等はtraceに含まれています。trace.payload.messageをみると「Hi, what's your name?」と返ってきているのがわかりますね。

...snip...
  "trace": [
    {
      "type": "text",
      "payload": {
        "slate": {
          "id": "5z53goa",
          "content": [
            {
              "children": [
                {
                  "text": "Hi, what's your name?"
                }
              ]
            }
          ]
        },
        "message": "Hi, what's your name?"
      }
    },
...snip...

そしてユーザの状態はstateに入っています。State APIの場合はVoiiceflow側で持ってる「状態」を、Stateless APIだと毎回渡されるわけですね。その他にも、会話フローにおける現在のブロック(stack)や変数(variables)と思しきものが含まれていますね。

...snip...
  "state": {
    "stack": [
      {
        "nodeID": null,
        "programID": "61f664249192710006fe1917",
        "storage": {},
        "commands": [],
        "variables": {}
      },
      {
        "nodeID": "61f664438e7a1488ab44a345",
        "programID": "61f664249192710006fe1918",
        "storage": {
          "output": [
            {
              "children": [
                {
                  "text": "Hi, what's your name?"
                }
              ]
            }
          ]
        },
        "commands": [],
        "variables": {}
      }
    ],
    "storage": {
      "dm": {
        "intentRequest": {
          "type": "intent",
          "payload": {
            "intent": {
              "name": "capture_tmp_user_name_pmdkppeb"
            },
            "query": "",
            "entities": []
          },
          "ELICIT": true
        }
      }
    },
    "variables": {
      "tmp_user_name": 0,
      "user_name": 0,
      "intent_confidence": 94.95,
      "last_utterance": "hello",
      "timestamp": 1643723759
    }
  },
...snip...

ではこの情報をもとに、会話フローを続けます。発話をactionとして送ると同時に受け取ったstateもセットでリクエストを送ります。

まずアクションはこんな感じで、名前を答えます。

{
  "action": {
    "type": "text",
    "payload": "my name is john"
  }
}

これを先ほどのstateとくっつけます。JSONのフォーマットが崩れないようにご注意ください。

{
  "action": {
    "type": "text",
    "payload": "my name is john"
  },
  "state": {
    "stack": [
      {
        "nodeID": null,
        "programID": "61f664249192710006fe1917",
        "storage": {},
        "commands": [],
        "variables": {}
      },
      {
        "nodeID": "61f664438e7a1488ab44a345",
        "programID": "61f664249192710006fe1918",
        "storage": {
          "output": [
            {
              "children": [
                {
                  "text": "Hi, what's your name?"
                }
              ]
            }
          ]
        },
        "commands": [],
        "variables": {}
      }
    ],
    "storage": {
      "dm": {
        "intentRequest": {
          "type": "intent",
          "payload": {
            "intent": {
              "name": "capture_tmp_user_name_pmdkppeb"
            },
            "query": "",
            "entities": []
          },
          "ELICIT": true
        }
      }
    },
    "variables": {
      "tmp_user_name": 0,
      "user_name": 0,
      "intent_confidence": 94.95,
      "last_utterance": "hello",
      "timestamp": 1643723759
    }
  }
}

リクエストを送ります。

$ curl -s -X GET "https://general-runtime.voiceflow.com/interact/state" -H "Authorization: $API_KEY" -H 'Content-Type: application/json' | jq .
> {
>   "action": {
>     "type": "text",
>     "payload": "my name is john"
>   },
...snip...
>   }
> }
> EOF

レスポンスを見てみましょう。

{
  "request": {
    "type": "intent",
    "payload": {
      "intent": {
        "name": "capture_tmp_user_name_pmdkppeb"
      },
      "query": "",
      "entities": [
        {
          "name": "tmp_user_name",
          "value": "john",
          "verboseValue": [
            {
              "rawText": "john",
              "canonicalText": "john",
              "startIndex": 22
            }
          ]
        }
      ]
    },
    "ELICIT": true
  },
  "state": {
    "stack": [],
    "storage": {},
    "variables": {
      "tmp_user_name": "john",
      "user_name": "john",
      "intent_confidence": 100,
      "last_utterance": "hello",
      "timestamp": 1643724073
    }
  },
  "trace": [
    {
      "type": "path",
      "payload": {
        "path": "capture"
      }
    },
    {
      "type": "text",
      "payload": {
        "slate": {
          "id": "5qk3g9v",
          "content": [
            {
              "children": [
                {
                  "text": "Hi, "
                },
                {
                  "text": "john"
                },
                {
                  "text": " . nice to meet you."
                }
              ]
            }
          ]
        },
        "message": "Hi, john . nice to meet you."
      }
    },
    {
      "type": "end"
    }
  ]
}

trace.payload.messageに「Hi, john . nice to meet you.」と返ってきて会話が続いたのがわかりますね。

...snip...
        "message": "Hi, john . nice to meet you."
...snip...

そして、state。ここにuser_nameというのがあります。プロジェクトの中ではuser_nameはGlobal Variables、つまり永続的な変数としてユーザ名を覚えておくために用意してあるものです。一つ前のレスポンスを見てみましょう。

...snip...
    "variables": {
      "tmp_user_name": 0,
      "user_name": 0,
      "intent_confidence": 94.95,
      "last_utterance": "hello",
      "timestamp": 1643723759
    }
...snip...

user_nameが空(Voiceflowでは変数初期化後は0になる)になってますね。

その次のレスポンスではこうなってました

...snip...
  "state": {
    "stack": [],
    "storage": {},
    "variables": {
      "tmp_user_name": "john",
      "user_name": "john",
      "intent_confidence": 100,
      "last_utterance": "hello",
      "timestamp": 1643724073
    }
  }
...snip...

はい、user_nameに「John」が入っていますね。

つまり、リクエストにstateを含めて送ると、Voiceflow側で次の会話を続けるためにstateを書き換えて返してくれる、これを繰り返すことでステートレスなやりとりをしつつ、会話フローの状態を遷移させるということになります。

さて、これで会話は終わっているので、もう一度最初から会話を始めてみましょう。stateは上記の最後のレスポンスのものを使います。

{
  "action": {
    "type": "text",
    "payload": "hello again"
  },
  "state": {
    "stack": [],
    "storage": {},
    "variables": {
      "tmp_user_name": "john",
      "user_name": "john",
      "intent_confidence": 100,
      "last_utterance": "hello",
      "timestamp": 1643724073
    }
  }
}

リクエストを送ります。Traceだけ抜粋します。

$ curl -s -X POST "https://general-runtime.voiceflow.com/interact" -H "Authorization: $API_KEY" -H 'Content-Type: application/json' -d @- <<'EOF' | jq .
> {
>   "action": {
>     "type": "text",
>     "payload": "hello again"
>   },
>   "state": {
>     "stack": [],
>     "storage": {},
>     "variables": {
>       "tmp_user_name": "john",
>       "user_name": "john",
>       "intent_confidence": 100,
>       "last_utterance": "hello",
>       "timestamp": 1643724073
>     }
>   }
> }
> EOF
...snip...
  "trace": [
    {
      "type": "text",
      "payload": {
        "slate": {
          "id": "5qk3g9v",
          "content": [
            {
              "children": [
                {
                  "text": "Hi, "
                },
                {
                  "text": "john"
                },
                {
                  "text": " . nice to see you again."
                }
              ]
            }
          ]
        },
        "message": "Hi, john . nice to see you again."
      }
    },
    {
      "type": "end"
    }
  ]
}

今度は名前を聞いてきませんでしたね。つまりセッションをまたぐ永続的な状態としてすでに名前を持っているので聞かれなかったというわけです。

Stateless APIでは、Voiceflow側では状態に関する情報を持っていません。したがって、同じstateを投げると基本的には毎回同じ回答が返ってくることになります。これはちょっと面白いですね(もちろんVoiceflow側で状態を変更するようなアクション、例えばAPI stepとかGoogle Sheets stepとかを使うと、この限りではないです)

Node.jsで試す

Nodeでもcliっぽいサンプルを書いてみました。

const axios = require("axios");
const { cli } = require("cli-ux");

const API_KEY = process.env.API_KEY;

async function default_state() {
  console.log("...");

  const response = await axios({
    method: "GET",
    url: `https://general-runtime.voiceflow.com/interact/state`,
    headers: { Authorization: `${API_KEY}`,  },
  });
  const state =  { state: response.data };
  return state;
}

async function interact(state, request) {
  console.log("...");

  const response = await axios({
    method: "POST",
    url: `https://general-runtime.voiceflow.com/interact`,
    headers: { Authorization: API_KEY },
    data: { "action": request, state },
  });

  //console.log(JSON.stringify(response.data));

  for (const trace of response.data.trace) {
    switch (trace.type) {
      case "text":
      case "speak":
        console.log(trace.payload.message);
        break;
      case "end":
        return false;
    }
  }

  return response.data.state;
}

async function main() {
  let state = await default_state();
  console.log("Let's start");
  //console.log(JSON.stringify(state));
  state = await interact(state, { type: "text", payload: "start" });

  while (state) {
    const nextInput = await cli.prompt("> Say something");
    state = await interact(state, { type: "text", payload: nextInput });
  }
  console.log("The end! Start me again with `npm start`");
}

main();

実行してみるとこうなります。

$ node aaa.js
...
Let's start
...
Hi, what's your name?
> Say something: my name is john.
...
Hi, john . nice to meet you.
The end! Start me again with `npm start`

実行すると何もわかりませんが、中ではリクエストごとにstateを受け取って次のリクエストではそれを含めて送信しています。ちなみにdefault stateを最初に使って、手元のstate情報を初期化していたりもします。

まとめ

State APIに比べるとちょっとややこしいかもしれませんが、イメージとしては、Webアプリで常にCookieをやり取りしているような感じというのが近しい気がします。ちょうど今作っているWebチャットボットの場合、ログイン機能はない前提なので、セッションCookieを使ってバックエンド側でStateを管理するようにして、Stateless APIを使うとてもよいユースケースになるかなと思っています。