Бот вопросов и ответов
В этом руководстве мы соберём основу бота, который отвечает на вопросы по вашей базе знаний: в чате, в вашем справочном центре или внутри вашего продукта. Ассистент находит нужные знания и отвечает, ссылаясь на карточки, которые использовал; ваш код отправляет вопрос и показывает результат.
Нужен ключ с областями 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.- Оценки отправляются обратно, чтобы база знаний со временем становилась лучше.