本文へスキップ
Sursa Developers

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なしで終わります。下記を参照してください。
followupsdoneの後に届く、次の質問の候補。ボタンとして提示できます。
その他スキップしてください。新しいイベントタイプはいつでも追加されることがあります。

:で始まる行(回答の準備中に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の処理。
  • ナレッジベースが時間とともに改善されるように、評価を送り返す仕組み。