Перейти к содержанию
Sursa для разработчиков

Бот вопросов и ответов

В этом руководстве мы соберём основу бота, который отвечает на вопросы по вашей базе знаний: в чате, в вашем справочном центре или внутри вашего продукта. Ассистент находит нужные знания и отвечает, ссылаясь на карточки, которые использовал; ваш код отправляет вопрос и показывает результат.

Нужен ключ с областями search и ask. Ответ использует только карточки, которые ключу разрешено читать, поэтому то, о чём бот может говорить, определяют роль, профиль доступа и коллекции ключа. Боту для клиентов давайте ключ, который видит только общедоступные коллекции.

Три способа получить ответ

ОперацияЧто возвращаетДля чего использовать
POST /v1/searchПодходящие отрывки, сначала самые подходящие. Готового ответа нет.Поле поиска, «похожие статьи», собственная сортировка результатов.
POST /v1/askГотовый ответ с источниками одним ответом.Боты, которые публикуют по одному сообщению на ответ; серверные задания.
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] соответствуют ref записей в sources; свяжите каждую с карточкой (doc_id), чтобы читатель мог проверить ответ.

Число в тексте, которое не удалось подтвердить по документам, может появиться в виде метки вроде ⟦n:a1⟧. verification.items описывает каждую метку: её marker, причину в reason и исходный текст в original. Если ваш код не обрабатывает метки, замените каждую её текстом из original.

Когда ответа нет

Ответ всегда 200. Когда ассистент не может ответить, empty говорит почему, а в answer лежит короткое сообщение, которое можно показать:

emptyЗначение
no_hitsВ базе знаний нет ничего, что подходит под вопрос.
restrictedТема есть в карточках, которые ключу читать нельзя.
guardrailВопрос выходит за рамки того, на что организация разрешает отвечать ассистенту.
no_data_for_periodВопрос касается периода, за который нет документов.

no_hits — полезный сигнал: он показывает, о чём спрашивают ваши пользователи, но чего база знаний пока не охватывает.

Режимы «Быстро» и «Точно»

Передайте "quality": "precise", чтобы получить более тщательный ответ на сложный вопрос ценой большего времени. Режим по умолчанию, "fast", подходит для большинства вопросов.

Ответ потоком

В интерфейсе чата показывайте ответ по мере написания. POST /v1/chat с "stream": true отвечает в формате text/event-stream: по событию на сообщение, у каждого есть строка id: (его номер в потоке, начиная с 1) и строка data: с JSON. Поле type в JSON говорит, что это за событие.

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: предлагаемые следующие вопросы, которые можно показать кнопками.
что-либо другоеПропустите. Новые типы событий могут появиться в любой момент.

Строка, которая начинается с : (например, : ping, который приходит каждые 15 секунд, пока готовится ответ), — комментарий. Пропускайте её.

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 возвращает готовый ответ в виде JSON, как /v1/ask.

Как возобновить оборвавшийся поток

Соединения рвутся: меняется мобильная сеть, прокси обрывает по тайм-ауту, ваш сервер перезапускается. Давайте каждому вопросу request_id, и ответ переживёт обрыв:

  • С request_id ответ готовится независимо от соединения. Разрыв его не останавливает: ответ доводится до конца и сохраняется.
  • Чтобы продолжить, отправьте тот же запрос ещё раз — с тем же request_id и номером последнего полученного события в заголовке Last-Event-ID.
  • Используйте один request_id (например, UUID) на вопрос и тот же самый при каждом повторе этого вопроса. Новый вопрос получает новый request_id.

Что получит повторный запрос, зависит от того, на каком этапе ответ:

СитуацияЧто получает повторный запрос
Ответ ещё пишется или только что завершилсяСобытия после Last-Event-ID с теми же номерами: поток просто продолжается.
Ответ завершился несколько минут назадСохранённый ответ: token со всем текстом и done, с нумерацией от 1. Вопрос заново не задаётся.
Поток нельзя продолжить с этого номераОтвет с самого начала, с нумерацией от 1.

Все три случая ваш код обрабатывает одним правилом: если номер первого полученного события не больше 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 или 429 с Retry-After, когда организация исчерпала свой лимит вопросов или у неё одновременно готовится слишком много ответов.

Когда поток уже начался, его 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 на каждый вопрос и правило возобновления, описанное выше.
  • Тайм-аут чтения потока дольше 15 секунд, чтобы комментарии : ping поддерживали соединение.
  • Ответы отображаются как Markdown, а ссылки ведут на свои источники.
  • Понятное сообщение, когда задано empty, и способ связаться с человеком.
  • 429 обрабатывается ожиданием Retry-After.
  • Оценки отправляются обратно, чтобы база знаний со временем становилась лучше.