Перейти до вмісту
Sursa Developers

Створіть бота запитань і відповідей

У цьому посібнику ви створите основу бота, що відповідає на запитання з вашої бази знань: у чаті, у вашому центрі допомоги чи всередині вашого продукту. Асистент знаходить потрібні знання й відповідає, посилаючись на картки, які використав; ваш код надсилає запитання й показує результат.

Вам потрібен ключ з областями доступу search і ask. Відповідь використовує лише картки, які ключу дозволено читати, тож роль ключа, його профіль доступу та колекції визначають, про що може говорити бот. Ботові для клієнтів надайте ключ, який бачить лише публічні колекції.

Три способи отримати відповідь

ОпераціяЩо повертаєДля чого використовувати
POST /v1/searchУривки, що відповідають запиту, найкращі першими. Без написаної відповіді.Поле пошуку, «схожі статті», власне впорядкування результатів.
POST /v1/askПовну відповідь із джерелами в одній відповіді HTTP.Боти, що публікують одне повідомлення на відповідь; фонові завдання на сервері.
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.

Коли відповіді немає

Відповідь HTTP завжди має статус 200. Коли асистент не може відповісти, empty пояснює чому, а answer містить коротке повідомлення, яке можна показати:

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

no_hits — корисний сигнал: він показує, про що запитують ваші користувачі, чого база знань ще не охоплює.

Режими «Fast» і «Precise»

Передайте "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.
  • Оцінки, що надсилаються назад, щоб база знань із часом ставала кращою.