Przejdź do treści
Sursa Developers

Zbuduj bota odpowiadającego na pytania

Ten przewodnik buduje podstawę bota, który odpowiada na pytania na podstawie Twojej bazy wiedzy: w komunikatorze, w Twoim centrum pomocy lub w Twoim produkcie. Asystent znajduje potrzebną wiedzę i odpowiada, powołując się na karty, z których skorzystał; Twój kod wysyła pytanie i pokazuje wynik.

Potrzebujesz klucza z zakresami search i ask. Odpowiedź korzysta tylko z kart, które klucz może odczytywać, więc rola klucza, jego profil dostępu i kolekcje decydują o tym, o czym bot może mówić. Botowi dla klientów daj klucz, który widzi tylko publiczne kolekcje.

Trzy sposoby uzyskania odpowiedzi

OperacjaZwracaZastosowanie
POST /v1/searchPasujące urywki, od najlepiej dopasowanego. Bez gotowej odpowiedzi tekstowej.Pole wyszukiwania, „powiązane artykuły”, własny ranking.
POST /v1/askPełną odpowiedź ze źródłami w jednej odpowiedzi HTTP.Boty, które publikują jedną wiadomość na odpowiedź; zadania po stronie serwera.
POST /v1/chatOdpowiedź jako strumień zdarzeń, w miarę jej powstawania.Interfejsy czatu, w których użytkownik powinien widzieć, jak pojawia się odpowiedź.
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"}'

Każde trafienie to urywek karty: doc_id i doc_name karty, heading sekcji, text urywka, strony lub wiersze tabeli, w których go znaleziono, oraz zaufanie i weryfikacja karty.

collection wybiera kolekcję do przeszukania; bez tego parametru używana jest kolekcja o nazwie default. tags i card_ids zawężają wyszukiwanie do kart z tymi tagami lub do konkretnych kart.

Zadaj pytanie i otrzymaj pełną odpowiedź

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"}'

Tekst odpowiedzi w answer jest w formacie Markdown. Jego odwołania, np. [1] lub [1, 3], odpowiadają polu ref wpisów w sources; połącz każde z nich z kartą (doc_id), aby czytelnik mógł sprawdzić odpowiedź.

Liczba w tekście, której nie udało się potwierdzić w dokumentach, może pojawić się jako znacznik, np. ⟦n:a1⟧. verification.items opisuje każdy znacznik: jego marker, przyczynę w reason i tekst z original. Jeśli Twój kod nie obsługuje znaczników, zastąp każdy z nich jego tekstem z original.

Gdy nie ma odpowiedzi

Odpowiedź HTTP ma zawsze status 200. Gdy asystent nie może odpowiedzieć, empty podaje przyczynę, a answer zawiera krótki komunikat, który możesz pokazać:

emptyZnaczenie
no_hitsNic w bazie wiedzy nie pasuje do pytania.
restrictedTemat znajduje się w kartach, których klucz nie może odczytywać.
guardrailPytanie wykracza poza to, na co organizacja pozwala asystentowi odpowiadać.
no_data_for_periodPytanie dotyczy okresu, dla którego nie ma dokumentów.

no_hits to przydatny sygnał: pokazuje, o co pytają Twoi użytkownicy, a czego baza wiedzy jeszcze nie obejmuje.

Tryby „Fast” i „Precise”

Przekaż "quality": "precise", aby uzyskać dokładniejszą odpowiedź na trudne pytanie kosztem dłuższego czasu. Wartość domyślna, "fast", sprawdza się w przypadku większości pytań.

Przesyłaj odpowiedź strumieniowo

W interfejsie czatu pokazuj odpowiedź w miarę jej powstawania. POST /v1/chat z "stream": true odpowiada w formacie text/event-stream: jedno zdarzenie na wiadomość, każde z wierszem id: (jego numer w strumieniu, od 1) i wierszem data: z JSON. Pole type w JSON mówi, czym jest zdarzenie.

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":[…]}
typeCo zrobić
metaOdpowiedź się rozpoczęła. Zachowaj answer_id, jeśli zapisujesz odpowiedzi w dzienniku.
sourcesKarty, na których opiera się odpowiedź. Może przyjść więcej niż raz; obowiązuje ostatnie.
tokenFragment tekstu odpowiedzi. Dołącz go do tego, co już pokazujesz.
doneOdpowiedź jest kompletna. Ma te same pola co odpowiedź /v1/ask.
errorOdpowiedź została przerwana. Strumień kończy się bez done. Zob. poniżej.
followupsPo done: propozycje kolejnych pytań, które możesz pokazać jako przyciski.
cokolwiek innegoPomiń. Nowe typy zdarzeń mogą pojawić się w każdej chwili.

Wiersz zaczynający się od : (np. : ping, wysyłany co 15 sekund podczas przygotowywania odpowiedzi) to komentarz. Pomiń go.

messages zawiera dotychczasową rozmowę, od najstarszej wiadomości, kończąc na pytaniu użytkownika. Wysyłaj kilka ostatnich tur, aby pytania uzupełniające, np. „a dla zamówień z zagranicy?”, były zrozumiałe.

// 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;
}

Bez "stream": true POST /v1/chat zwraca pełną odpowiedź jako JSON, tak jak /v1/ask.

Wznów przerwany strumień

Połączenia się zrywają: zmienia się sieć komórkowa, serwer proxy przekracza limit czasu, Twój serwer uruchamia się ponownie. Nadaj każdemu pytaniu request_id, a odpowiedź przetrwa przerwę:

  • Z request_id odpowiedź jest przygotowywana niezależnie od połączenia. Rozłączenie jej nie zatrzymuje: zostaje dokończona i zapisana.
  • Aby wznowić, wyślij ponownie to samo żądanie z tym samym request_id i numerem ostatniego otrzymanego zdarzenia w nagłówku Last-Event-ID.
  • Używaj jednego request_id (np. UUID) na pytanie i tego samego przy każdym ponowieniu tego pytania. Nowe pytanie otrzymuje nowy request_id.

To, co otrzyma powtórzone żądanie, zależy od tego, na jakim etapie jest odpowiedź:

SytuacjaPowtórzone żądanie otrzymuje
Odpowiedź jest wciąż przygotowywana lub właśnie się zakończyłaZdarzenia po Last-Event-ID, z tymi samymi numerami: strumień po prostu trwa dalej.
Odpowiedź zakończyła się kilka minut temuZapisaną odpowiedź jako token z całym tekstem i done, numerowane od 1. Pytanie nie jest zadawane ponownie.
Strumienia nie można kontynuować od tego numeruOdpowiedź od początku, numerowaną od 1.

Twój kod obsługuje wszystkie trzy przypadki jedną zasadą: jeśli pierwsze otrzymane zdarzenie ma numer nie większy niż Last-Event-ID, odpowiedź zaczyna się od nowa; wyczyść to, co pokazano, i zbuduj ją ponownie. W przeciwnym razie dołączaj kolejne fragmenty.

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 może działać na kilku serwerach. Strumień jest kontynuowany od Last-Event-ID tylko na serwerze, który przygotowuje odpowiedź; jeśli powtórzone żądanie trafi na inny serwer, otrzyma zapisaną odpowiedź, gdy będzie gotowa, albo odpowiedź zostanie przygotowana od początku. Powyższa zasada obejmuje oba przypadki, a pytanie nigdy nie jest liczone dwukrotnie.

Bez request_id zamknięcie połączenia anuluje odpowiedź.

Błędy w strumieniu

Błędy przed rozpoczęciem strumienia to zwykłe błędy HTTP: 401, 403, 422 lub 429 z Retry-After, gdy organizacja wykorzystała swój limit pytań lub ma naraz zbyt wiele odpowiedzi w toku.

Po rozpoczęciu strumienia jego status HTTP to zawsze 200, a problem przychodzi jako zdarzenie error:

{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
  • retry: true oznacza, że odpowiedź została przerwana z przyczyn po stronie serwera, np. podczas aktualizacji, i możesz wysłać to samo żądanie ponownie bez zmian.
  • Inne kody, np. timeout, są wymienione w dokumentacji API. Nieznany kod traktuj jak każdy inny błąd strumienia: poinformuj użytkownika i pozwól mu zapytać ponownie.

Pozwól użytkownikom oceniać odpowiedzi

Każda odpowiedź ma question_id. Wyślij ocenę użytkownika do POST /v1/questions/{question_id}/feedback: 1 – pomocna, -1 – niepomocna, z opcjonalnymi reason i note. Oceny pomagają organizacji zobaczyć, które karty wymagają pracy.

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."}'

Lista kontrolna bota produkcyjnego

  • Klucz tylko z search i ask, ograniczony do kolekcji, które mogą widzieć odbiorcy bota.
  • request_id dla każdego pytania i powyższa zasada wznawiania.
  • Limit czasu odczytu strumienia dłuższy niż 15 sekund, aby komentarze : ping podtrzymywały połączenie.
  • Odpowiedzi renderowane jako Markdown, z odwołaniami połączonymi ze źródłami.
  • Jasny komunikat, gdy ustawione jest empty, i możliwość kontaktu z człowiekiem.
  • Obsługa 429 przez odczekanie czasu z Retry-After.
  • Odsyłane oceny, aby baza wiedzy z czasem stawała się lepsza.