Direct naar de inhoud
Sursa Developers

Een vraag-en-antwoordbot bouwen

In deze handleiding bouw je de kern van een bot die vragen beantwoordt op basis van je kennisbank: in een chattool, in je helpcenter of in je eigen product. De assistent vindt de juiste kennis en antwoordt, met verwijzingen naar de kaarten die hij heeft gebruikt; je code stuurt de vraag en toont het resultaat.

Je hebt een sleutel nodig met de scopes search en ask. Het antwoord gebruikt alleen de kaarten die de sleutel mag lezen, dus de rol, het dataprofiel en de collecties van de sleutel bepalen waarover de bot kan praten. Geef een bot voor klanten een sleutel die alleen openbare collecties ziet.

Drie manieren om een antwoord te krijgen

EndpointGeeft terugGebruik het voor
POST /v1/searchDe passages die overeenkomen, de beste eerst. Geen geschreven antwoord.Een zoekvak, “gerelateerde artikelen”, je eigen rangschikking.
POST /v1/askEen volledig antwoord met de bronnen, in één response.Bots die één bericht per antwoord plaatsen; backendjobs.
POST /v1/chatHet antwoord als stream van events, terwijl het wordt geschreven.Chatinterfaces, waar de gebruiker het antwoord moet zien verschijnen.
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"}'

Elke treffer is een passage van een kaart: doc_id en doc_name van de kaart, heading van de sectie, text van de passage, de pagina’s of tabelrijen waar hij is gevonden, en de betrouwbaarheid en verificatie van de kaart.

collection kiest de collectie die wordt doorzocht; zonder deze parameter wordt de collectie met de naam default gebruikt. tags en card_ids beperken de zoekopdracht tot kaarten met die tags of tot bepaalde kaarten.

Vragen stellen en een volledig antwoord krijgen

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

De tekst van het antwoord in answer is Markdown. De verwijzingen erin, zoals [1] of [1, 3], komen overeen met ref van de items in sources; link elke verwijzing naar de kaart (doc_id), zodat de lezer het antwoord kan controleren.

Een getal in de tekst dat niet aan de hand van de documenten kon worden bevestigd, kan verschijnen als een markering zoals ⟦n:a1⟧. verification.items beschrijft elke markering: de marker, de reason en de original-tekst ervan. Als je code geen markeringen verwerkt, vervang je elke markering door de original-tekst ervan.

Als er geen antwoord is

De response is altijd 200. Kan de assistent niet antwoorden, dan zegt empty waarom, en bevat answer een korte melding die je kunt tonen:

emptyBetekenis
no_hitsNiets in de kennisbank past bij de vraag.
restrictedHet onderwerp staat in kaarten die de sleutel niet mag lezen.
guardrailDe vraag valt buiten wat de organisatie de assistent laat beantwoorden.
no_data_for_periodDe vraag gaat over een periode waarvoor geen documenten zijn.

no_hits is een nuttig signaal: het laat zien wat je gebruikers vragen dat de kennisbank nog niet behandelt.

Fast en Precise

Geef "quality": "precise" mee voor een grondiger antwoord op een moeilijke vraag; dat kost meer tijd. De standaard, "fast", past bij de meeste vragen.

Het antwoord streamen

Toon in een chatinterface het antwoord terwijl het wordt geschreven. POST /v1/chat met "stream": true antwoordt met text/event-stream: één event per bericht, elk met een regel id: (het nummer in de stream, vanaf 1) en een regel data: met JSON. Het veld type van de JSON zegt wat voor event het is.

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":[…]}
typeWat je doet
metaHet antwoord is begonnen. Bewaar answer_id als je antwoorden logt.
sourcesDe kaarten waarop het antwoord is gebaseerd. Kan meer dan eens komen; de laatste telt.
tokenEen stuk van de tekst van het antwoord. Voeg het toe aan wat je toont.
doneHet antwoord is compleet. Het heeft dezelfde velden als de response van /v1/ask.
errorHet antwoord is gestopt. De stream eindigt zonder done. Zie hieronder.
followupsNa done: voorgestelde vervolgvragen die je als knoppen kunt aanbieden.
al het andereOverslaan. Er kunnen op elk moment nieuwe eventtypes verschijnen.

Een regel die begint met : (zoals : ping, die elke 15 seconden wordt verstuurd zolang het antwoord wordt voorbereid) is een opmerking. Sla hem over.

messages bevat het gesprek tot nu toe, de oudste eerst, eindigend met de vraag van de gebruiker. Stuur de laatste paar beurten mee, zodat vervolgvragen zoals “en voor bestellingen uit het buitenland?” worden begrepen.

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

Zonder "stream": true geeft POST /v1/chat het volledige antwoord terug als JSON, zoals /v1/ask.

Een afgebroken stream hervatten

Verbindingen breken: een mobiel netwerk wisselt, een proxy krijgt een time-out, je server herstart. Geef elke vraag een request_id en het antwoord overleeft de onderbreking:

  • Met request_id wordt het antwoord los van de verbinding voorbereid. Een verbroken verbinding stopt het niet: het wordt afgemaakt en opgeslagen.
  • Om te hervatten stuur je hetzelfde request opnieuw, met dezelfde request_id en het nummer van het laatste event dat je hebt ontvangen in de header Last-Event-ID.
  • Gebruik één request_id (bijvoorbeeld een UUID) per vraag, en dezelfde bij elke nieuwe poging voor die vraag. Een nieuwe vraag krijgt een nieuwe request_id.

Wat het herhaalde request krijgt, hangt af van waar het antwoord is:

SituatieHet herhaalde request ontvangt
Het antwoord wordt nog geschreven of is net klaarDe events na Last-Event-ID, met dezelfde nummers: de stream gaat gewoon verder.
Het antwoord is een paar minuten geleden klaargekomenHet opgeslagen antwoord als een token met de hele tekst en een done, genummerd vanaf 1. De vraag wordt niet opnieuw gesteld.
De stream kan niet vanaf dat nummer verderHet antwoord vanaf het begin, genummerd vanaf 1.

Je code behandelt alle drie de gevallen met één regel: heeft het eerste event dat je ontvangt een nummer dat niet groter is dan Last-Event-ID, dan begint het antwoord opnieuw; wis wat je hebt getoond en bouw het opnieuw op. Anders voeg je toe.

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

De Sursa API kan op meerdere servers draaien. Een stream gaat vanaf Last-Event-ID alleen verder op de server die het antwoord schrijft; komt je herhaalde request bij een andere server terecht, dan krijgt het het opgeslagen antwoord zodra dat klaar is, of wordt het antwoord vanaf het begin opnieuw geschreven. De regel hierboven dekt beide gevallen, en de vraag wordt nooit twee keer geteld.

Zonder request_id annuleert het sluiten van de verbinding het antwoord.

Fouten in een stream

Fouten voordat de stream begint, zijn gewone HTTP-fouten: 401, 403, 422, of 429 met Retry-After als de organisatie haar limiet voor vragen heeft opgebruikt of te veel antwoorden tegelijk in behandeling heeft.

Is de stream eenmaal begonnen, dan is de HTTP-status altijd 200 en komt een probleem binnen als een event error:

{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
  • retry: true betekent dat het antwoord is gestopt door de server, bijvoorbeeld tijdens een update, en dat je hetzelfde request ongewijzigd opnieuw kunt versturen.
  • Andere codes, zoals timeout, staan in de API-referentie. Behandel een onbekende code zoals elke andere fout in de stream: meld het aan de gebruiker en laat die de vraag opnieuw stellen.

Gebruikers antwoorden laten beoordelen

Elk antwoord heeft een question_id. Stuur de beoordeling van de gebruiker naar POST /v1/questions/{question_id}/feedback: 1 voor nuttig, -1 voor niet nuttig, met optioneel een reason en een note. Beoordelingen helpen de organisatie te zien welke kaarten werk nodig hebben.

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

Checklist voor een bot in productie

  • Een sleutel met alleen search en ask, beperkt tot de collecties die het publiek van de bot mag zien.
  • Een request_id per vraag, en de regel voor hervatten hierboven.
  • Een time-out voor het lezen van de stream die langer is dan 15 seconden, zodat de opmerkingen : ping hem in leven houden.
  • Antwoorden weergegeven als Markdown, met verwijzingen die naar hun bronnen linken.
  • Een duidelijke melding als empty is ingesteld, en een manier om een mens te bereiken.
  • 429 afgehandeld door op Retry-After te wachten.
  • Beoordelingen die worden teruggestuurd, zodat de kennisbank na verloop van tijd beter wordt.