Zum Inhalt springen
Sursa Developers

Q&A-Bot bauen

Diese Anleitung baut den Kern eines Bots, der Fragen aus Ihrer Wissensbasis beantwortet: in einem Chat-Tool, in Ihrem Help-Center oder in Ihrem Produkt. Der Assistent findet das passende Wissen und antwortet, wobei er die verwendeten Karten zitiert; Ihr Code sendet die Frage und zeigt das Ergebnis.

Sie brauchen einen Schlüssel mit den Scopes search und ask. Die Antwort verwendet nur die Karten, die der Schlüssel lesen darf. Rolle, Datenprofil und Sammlungen des Schlüssels bestimmen also, worüber der Bot sprechen kann. Geben Sie einem Bot für Kunden einen Schlüssel, der nur öffentliche Sammlungen sieht.

Drei Wege zu einer Antwort

EndpunktGibt zurückVerwenden Sie ihn für
POST /v1/searchDie passenden Textstellen, die beste zuerst. Keine formulierte Antwort.Ein Suchfeld, „ähnliche Artikel“, Ihre eigene Rangfolge.
POST /v1/askEine vollständige Antwort mit ihren Quellen in einer einzigen Antwort.Bots, die eine Nachricht pro Antwort posten; Backend-Jobs.
POST /v1/chatDie Antwort als Stream von Ereignissen, während sie entsteht.Chat-Oberflächen, in denen die Antwort sichtbar entstehen soll.
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"}'

Jeder Treffer ist eine Textstelle einer Karte: doc_id und doc_name der Karte, heading des Abschnitts, text der Textstelle, die Seiten oder Tabellenzeilen, in denen sie gefunden wurde, sowie Vertrauenswert und Prüfstatus der Karte.

collection wählt die zu durchsuchende Sammlung; ohne diesen Parameter wird die Sammlung mit dem Namen default verwendet. tags und card_ids beschränken die Suche auf Karten mit diesen Tags oder auf bestimmte Karten.

Fragen und eine vollständige Antwort erhalten

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

Der Text der Antwort in answer ist Markdown. Seine Verweise wie [1] oder [1, 3] entsprechen ref der Einträge in sources; verlinken Sie jeden davon mit der Karte (doc_id), damit Lesende die Antwort überprüfen können.

Eine Zahl im Text, die nicht anhand der Dokumente bestätigt werden konnte, kann als Markierung wie ⟦n:a1⟧ erscheinen. verification.items beschreibt jede Markierung: ihren marker, den reason und den Text aus original. Wenn Ihr Code keine Markierungen verarbeitet, ersetzen Sie jede durch ihren Text aus original.

Wenn es keine Antwort gibt

Die Antwort ist immer 200. Wenn der Assistent nicht antworten kann, sagt empty, warum, und answer enthält eine kurze Meldung, die Sie anzeigen können:

emptyBedeutung
no_hitsNichts in der Wissensbasis passt zur Frage.
restrictedDas Thema steht in Karten, die der Schlüssel nicht lesen darf.
guardrailDie Frage liegt außerhalb dessen, was der Assistent nach dem Willen der Organisation beantworten darf.
no_data_for_periodDie Frage betrifft einen Zeitraum, für den es keine Dokumente gibt.

no_hits ist ein nützliches Signal: Es zeigt, wonach Ihre Benutzer fragen, was die Wissensbasis noch nicht abdeckt.

Schnell und präzise

Übergeben Sie "quality": "precise" für eine gründlichere Antwort auf eine schwierige Frage, die dafür mehr Zeit braucht. Der Standardwert "fast" passt zu den meisten Fragen.

Antwort streamen

Zeigen Sie die Antwort in einer Chat-Oberfläche, während sie entsteht. POST /v1/chat mit "stream": true antwortet mit text/event-stream: ein Ereignis pro Nachricht, jedes mit einer Zeile id: (seine Nummer im Stream, ab 1) und einer Zeile data: mit JSON. Das Feld type im JSON sagt, um welches Ereignis es sich handelt.

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":[…]}
typeWas zu tun ist
metaDie Antwort hat begonnen. Bewahren Sie answer_id auf, wenn Sie Antworten protokollieren.
sourcesDie Karten, auf denen die Antwort beruht. Kann mehrmals kommen; das letzte gilt.
tokenEin Stück des Texts der Antwort. Hängen Sie es an das Angezeigte an.
doneDie Antwort ist vollständig. Es hat dieselben Felder wie die Antwort von /v1/ask.
errorDie Antwort wurde abgebrochen. Der Stream endet ohne done. Siehe unten.
followupsNach done: vorgeschlagene Folgefragen, die Sie als Schaltflächen anbieten können.
alles andereÜberspringen Sie es. Neue Ereignistypen können jederzeit hinzukommen.

Eine Zeile, die mit : beginnt (etwa : ping, alle 15 Sekunden gesendet, während die Antwort vorbereitet wird), ist ein Kommentar. Überspringen Sie sie.

messages enthält das bisherige Gespräch, die älteste Nachricht zuerst, und endet mit der Frage des Benutzers. Senden Sie die letzten Gesprächsrunden mit, damit Folgefragen wie „und für Bestellungen aus dem Ausland?“ verstanden werden.

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

Ohne "stream": true gibt POST /v1/chat wie /v1/ask die vollständige Antwort als JSON zurück.

Abgebrochenen Stream fortsetzen

Verbindungen brechen ab: Ein Mobilfunknetz wechselt, ein Proxy läuft in ein Timeout, Ihr Server startet neu. Geben Sie jeder Frage eine request_id, und die Antwort übersteht den Abbruch:

  • Mit request_id wird die Antwort unabhängig von der Verbindung vorbereitet. Ein Verbindungsabbruch hält sie nicht auf: Die Antwort wird fertiggestellt und gespeichert.
  • Zum Fortsetzen senden Sie dieselbe Anfrage erneut, mit derselben request_id und der Nummer des zuletzt empfangenen Ereignisses im Header Last-Event-ID.
  • Verwenden Sie eine request_id (zum Beispiel eine UUID) pro Frage und dieselbe bei jeder Wiederholung dieser Frage. Eine neue Frage erhält eine neue request_id.

Was die wiederholte Anfrage erhält, hängt davon ab, wo die Antwort gerade steht:

SituationDie wiederholte Anfrage erhält
Die Antwort entsteht noch oder ist gerade fertig gewordenDie Ereignisse nach Last-Event-ID mit denselben Nummern: Der Stream läuft einfach weiter.
Die Antwort ist seit einigen Minuten fertigDie gespeicherte Antwort als ein token mit dem ganzen Text und ein done, nummeriert ab 1. Die Frage wird nicht erneut gestellt.
Der Stream kann ab dieser Nummer nicht weiterlaufenDie Antwort von Anfang an, nummeriert ab 1.

Ihr Code behandelt alle drei Fälle mit einer Regel: Hat das erste empfangene Ereignis eine Nummer, die nicht größer als Last-Event-ID ist, beginnt die Antwort von vorn; leeren Sie das Angezeigte und bauen Sie es neu auf. Andernfalls hängen Sie an.

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

Die Sursa-API kann auf mehreren Servern laufen. Ein Stream setzt ab Last-Event-ID nur auf dem Server fort, der die Antwort gerade schreibt. Erreicht Ihre wiederholte Anfrage einen anderen, erhält sie die gespeicherte Antwort, sobald diese fertig ist, oder die Antwort wird von Anfang an neu geschrieben. Die Regel oben deckt beide Fälle ab, und die Frage wird nie doppelt gezählt.

Ohne request_id bricht das Schließen der Verbindung die Antwort ab.

Fehler in einem Stream

Fehler vor dem Start des Streams sind gewöhnliche HTTP-Fehler: 401, 403, 422 oder 429 mit Retry-After, wenn die Organisation ihr Fragenlimit aufgebraucht hat oder zu viele gleichzeitig laufende Antworten hat.

Sobald der Stream begonnen hat, ist sein HTTP-Status immer 200, und ein Problem kommt als Ereignis error an:

{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
  • retry: true bedeutet, dass die Antwort wegen des Servers abgebrochen wurde, zum Beispiel während eines Updates, und Sie dieselbe Anfrage unverändert erneut senden können.
  • Andere Codes wie timeout sind in der API-Referenz aufgeführt. Behandeln Sie einen unbekannten Code wie jeden anderen Stream-Fehler: Informieren Sie die Person und lassen Sie sie erneut fragen.

Bewertungen von Antworten ermöglichen

Jede Antwort hat eine question_id. Senden Sie die Bewertung an POST /v1/questions/{question_id}/feedback: 1 für hilfreich, -1 für nicht hilfreich, optional mit reason und note. Bewertungen helfen der Organisation zu erkennen, welche Karten überarbeitet werden müssen.

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

Checkliste für einen Bot im Produktivbetrieb

  • Ein Schlüssel nur mit search und ask, beschränkt auf die Sammlungen, die die Zielgruppe des Bots sehen darf.
  • Eine request_id pro Frage und die Regel zum Fortsetzen von oben.
  • Ein Timeout beim Lesen des Streams, das länger als 15 Sekunden ist, damit die Kommentare : ping ihn am Leben halten.
  • Antworten als Markdown dargestellt, mit Verweisen, die auf ihre Quellen verlinken.
  • Eine klare Meldung, wenn empty gesetzt ist, und ein Weg, einen Menschen zu erreichen.
  • 429 behandelt, indem auf Retry-After gewartet wird.
  • Bewertungen werden zurückgesendet, damit die Wissensbasis mit der Zeit besser wird.