Q&Aボットを作る
このガイドでは、チャットツール、ヘルプセンター、自社の製品の中で、ナレッジベースをもとに質問に回答するボットの中核部分を作ります。アシスタントは関連するナレッジを見つけ、使ったカードを示しながら回答します。コードは質問を送信し、結果を表示します。
searchとaskのスコープを持つキーが必要です。回答には、キーで読み込めるカードだけが使われるので、ボットが何について話せるかは、キーのロール、データプロファイル、コレクションで決まります。顧客向けのボットには、公開コレクションだけを参照できるキーを使ってください。
回答を得る3つの方法
| エンドポイント | 返すもの | 用途 |
|---|---|---|
POST /v1/search | 一致する抜粋を、一致度の高い順に。文章の回答はありません。 | 検索ボックス、「関連記事」、独自の並べ替え。 |
POST /v1/ask | 出典付きの完全な回答を1つのレスポンスで。 | 回答ごとに1つのメッセージを投稿するボット、バックエンドのジョブ。 |
POST /v1/chat | 書かれていく回答を、イベントのストリームとして。 | 回答が表示されていく様子をユーザーに見せるチャットのインターフェース。 |
検索
curl -X POST https://api.sursa.com/v1/search \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "how do I return a damaged item", "collection": "support"}'各ヒットはカードの抜粋です。カードのdoc_idとdoc_name、セクションのheading、抜粋のtext、見つかったページや表の行、カードの信頼と検証の状態を含みます。
collectionは検索するコレクションを選びます。指定しない場合は、defaultという名前のコレクションが使われます。tagsとcard_idsは、検索をそのタグを持つカードや特定のカードに絞り込みます。
質問して完全な回答を得る
curl -X POST https://api.sursa.com/v1/ask \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "How do I return a damaged item?", "collection": "support"}'answerの回答の本文はMarkdownです。[1]や[1, 3]のような参照は、sourcesの項目のrefに対応します。読み手が回答を確認できるように、それぞれをカード(doc_id)にリンクしてください。
ドキュメントと照らし合わせて確認できなかった本文中の数値は、⟦n:a1⟧のようなマーカーとして現れることがあります。verification.itemsには、各マーカーの説明としてmarker、reason、originalのテキストが入ります。コードでマーカーを扱わない場合は、各マーカーをそのoriginalのテキストに置き換えてください。
回答がないとき
レスポンスは常に200です。アシスタントが回答できないときは、emptyにその理由が入り、answerには表示できる短いメッセージが入ります。
empty | 意味 |
|---|---|
no_hits | ナレッジベースに質問に一致するものがない。 |
restricted | そのトピックは、キーで読み込めないカードにある。 |
guardrail | 質問が、組織がアシスタントに回答を許可している範囲の外にある。 |
no_data_for_period | 質問が、ドキュメントのない期間について尋ねている。 |
no_hitsは役に立つシグナルです。ユーザーが尋ねているのに、ナレッジベースがまだカバーしていないことがわかります。
FastとPrecise
難しい質問により詳しく回答させるには、時間はかかりますが、"quality": "precise"を渡してください。ほとんどの質問には、デフォルトの"fast"が適しています。
回答をストリーミングする
チャットのインターフェースでは、書かれていく回答をそのまま表示します。"stream": trueを付けたPOST /v1/chatは、text/event-streamで応答します。メッセージごとに1つのイベントがあり、それぞれにid:の行(ストリームでの番号。1から)と、JSONを含むdata:の行があります。JSONのtypeフィールドがイベントの種類を示します。
id: 1
data: {"type":"meta","sources":3,"timings":{…},"answer_id":"…"}
id: 2
data: {"type":"sources","sources":[{"ref":1,"doc_id":"8c1d…","doc_name":"Returns","text":"…"}]}
id: 3
data: {"type":"token","text":"Send a photo of the item "}
id: 4
data: {"type":"token","text":"within 7 days [1]."}
id: 5
data: {"type":"done","answer":"Send a photo of the item within 7 days [1].","question_id":9812,"sources":[…]}
type | 対処 |
|---|---|
meta | 回答が始まりました。回答をログに残す場合はanswer_idを保存してください。 |
sources | 回答のもとになるカード。複数回届くことがあり、最後のものが有効です。 |
token | 回答の本文の一部。表示中の内容に追加してください。 |
done | 回答が完了しました。/v1/askのレスポンスと同じフィールドを持ちます。 |
error | 回答が止まりました。ストリームはdoneなしで終わります。下記を参照してください。 |
followups | doneの後に届く、次の質問の候補。ボタンとして提示できます。 |
| その他 | スキップしてください。新しいイベントタイプはいつでも追加されることがあります。 |
:で始まる行(回答の準備中に15秒ごとに送信される: pingなど)はコメントです。スキップしてください。
messagesには、ここまでの会話を古い順に入れ、最後をユーザーの質問にします。「海外からの注文の場合は?」のような続きの質問が理解されるように、直近のいくつかのターンを送信してください。
// Streams an answer and calls onText with each piece of text. Node.js 20+ or a browser.
export async function streamAnswer({ messages, collection, requestId, lastEventId, onText }) {
const response = await fetch("https://api.sursa.com/v1/chat", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SURSA_API_KEY}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
...(lastEventId ? { "Last-Event-ID": String(lastEventId) } : {}),
},
body: JSON.stringify({ messages, collection, stream: true, request_id: requestId }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
const decoder = new TextDecoder();
let buffer = "";
let event = { id: 0, data: "" };
let done = null;
for await (const piece of response.body) {
buffer += decoder.decode(piece, { stream: true });
let newline;
while ((newline = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
if (line.startsWith("id:")) event.id = Number(line.slice(3).trim());
else if (line.startsWith("data:")) event.data += line.slice(5).trim();
else if (line === "" && event.data) {
const message = JSON.parse(event.data);
if (message.type === "token") onText(message.text, event.id);
else if (message.type === "done") done = message;
else if (message.type === "error") throw Object.assign(new Error(message.message), { event: message, id: event.id });
event = { id: 0, data: "" };
}
}
}
if (!done) throw new Error("the stream ended before the answer was complete"); // resume it
return done;
}"stream": trueなしのPOST /v1/chatは、/v1/askと同じく完全な回答をJSONで返します。
途切れたストリームを再開する
接続は切れるものです。モバイルネットワークが切り替わったり、プロキシーがタイムアウトしたり、自社のサーバーが再起動したりします。すべての質問にrequest_idを付ければ、回答は接続の切断を乗り越えます。
request_idがあると、回答は接続とは独立して用意されます。切断されても止まらず、回答は完成して保存されます。- 再開するには、同じ
request_idと、最後に受信したイベントの番号を入れたLast-Event-IDヘッダーを付けて、同じリクエストをもう一度送信します。 - 質問ごとに1つの
request_id(たとえばUUID)を使い、その質問のすべての再試行で同じ値を使います。新しい質問には新しいrequest_idを付けます。
繰り返したリクエストで何を受け取るかは、回答の状態によって決まります。
| 状況 | 繰り返したリクエストで受け取るもの |
|---|---|
| 回答がまだ書かれている途中か、完了した直後 | Last-Event-IDより後のイベントが同じ番号で届きます。ストリームはそのまま続きます。 |
| 回答が数分前に完了している | 保存済みの回答が、本文全体を含むtokenとdoneとして、1から番号を振って届きます。質問はもう一度行われません。 |
| その番号からストリームを続けられない | 1から番号を振った、最初からの回答。 |
コードは1つのルールで3つの場合すべてに対応できます。最初に受信したイベントの番号がLast-Event-ID以下なら、回答は最初からやり直しです。表示した内容を消去して、組み立て直してください。それ以外の場合は、追加していきます。
import { randomUUID } from "node:crypto";
async function answerWithResume(messages, collection, show) {
const requestId = randomUUID();
let text = "";
let lastId = 0;
for (let attempt = 1; ; attempt++) {
let first = true;
try {
return await streamAnswer({
messages, collection, requestId, lastEventId: lastId,
onText(piece, id) {
if (first && lastId && id <= lastId) text = ""; // the answer starts over
first = false;
text += piece;
lastId = id;
show(text);
},
});
} catch (error) {
const retry = error.event ? error.event.retry === true : true; // a network error, or a server restart
if (!retry || attempt === 5) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
}Sursa APIは複数のサーバーで動作することがあります。ストリームがLast-Event-IDから続くのは、回答を書いているサーバー上だけです。繰り返したリクエストが別のサーバーに届いた場合は、回答の完了後に保存済みの回答を受け取るか、回答が最初から書き直されます。上のルールはどちらの場合にも対応し、質問が二重に数えられることはありません。
request_idがない場合は、接続を閉じると回答はキャンセルされます。
ストリーム内のエラー
ストリームが始まる前のエラーは、通常のHTTPのエラーです:401、403、422、または組織が質問の上限を使い切ったときや、同時に生成中の回答が多すぎるときの、Retry-After付きの429です。
ストリームが始まった後は、HTTPステータスは常に200で、問題はerrorイベントとして届きます。
{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
retry: trueは、更新中などサーバー側の理由で回答が止まったことを意味します。同じリクエストを変更せずにもう一度送信できます。timeoutなどのその他のコードは、APIリファレンスに記載されています。未知のコードは他のストリームのエラーと同じように扱ってください。ユーザーに知らせ、もう一度質問できるようにします。
ユーザーに回答を評価してもらう
すべての回答にはquestion_idがあります。ユーザーの評価をPOST /v1/questions/{question_id}/feedbackに送信してください。役に立った場合は1、役に立たなかった場合は-1で、省略可能なreasonとnoteを付けられます。評価は、どのカードに手直しが必要かを組織が把握するのに役立ちます。
curl -X POST https://api.sursa.com/v1/questions/9812/feedback \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value": -1, "reason": "outdated", "note": "The return window is now 14 days."}'本番用ボットのチェックリスト
searchとaskだけを持ち、ボットの対象者が参照できるコレクションに限定したキー。- 質問ごとの
request_idと、上の再開のルール。 : pingのコメントで接続が保たれるように、15秒より長いストリーム読み込みのタイムアウト。- Markdownとして表示し、参照を出典にリンクした回答。
emptyが設定されたときのわかりやすいメッセージと、担当者に問い合わせる手段。Retry-Afterの時間だけ待つ429の処理。- ナレッジベースが時間とともに改善されるように、評価を送り返す仕組み。