Към съдържанието
Sursa Developers

Създаване на бот за въпроси и отговори

Това ръководство изгражда основата на бот, който отговаря на въпроси от базата ви от знания: в чат инструмент, в центъра ви за помощ или във вашия продукт. Асистентът намира нужните знания и отговаря, като цитира използваните карти; вашият код изпраща въпроса и показва резултата.

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

Режимите „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.
  • Изпращани обратно оценки, за да става базата от знания по-добра с времето.