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
| Endpoint | Geeft terug | Gebruik het voor |
|---|---|---|
POST /v1/search | De passages die overeenkomen, de beste eerst. Geen geschreven antwoord. | Een zoekvak, “gerelateerde artikelen”, je eigen rangschikking. |
POST /v1/ask | Een volledig antwoord met de bronnen, in één response. | Bots die één bericht per antwoord plaatsen; backendjobs. |
POST /v1/chat | Het antwoord als stream van events, terwijl het wordt geschreven. | Chatinterfaces, waar de gebruiker het antwoord moet zien verschijnen. |
Zoeken
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:
empty | Betekenis |
|---|---|
no_hits | Niets in de kennisbank past bij de vraag. |
restricted | Het onderwerp staat in kaarten die de sleutel niet mag lezen. |
guardrail | De vraag valt buiten wat de organisatie de assistent laat beantwoorden. |
no_data_for_period | De 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":[…]}
type | Wat je doet |
|---|---|
meta | Het antwoord is begonnen. Bewaar answer_id als je antwoorden logt. |
sources | De kaarten waarop het antwoord is gebaseerd. Kan meer dan eens komen; de laatste telt. |
token | Een stuk van de tekst van het antwoord. Voeg het toe aan wat je toont. |
done | Het antwoord is compleet. Het heeft dezelfde velden als de response van /v1/ask. |
error | Het antwoord is gestopt. De stream eindigt zonder done. Zie hieronder. |
followups | Na done: voorgestelde vervolgvragen die je als knoppen kunt aanbieden. |
| al het andere | Overslaan. 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_idwordt 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_iden het nummer van het laatste event dat je hebt ontvangen in de headerLast-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 nieuwerequest_id.
Wat het herhaalde request krijgt, hangt af van waar het antwoord is:
| Situatie | Het herhaalde request ontvangt |
|---|---|
| Het antwoord wordt nog geschreven of is net klaar | De events na Last-Event-ID, met dezelfde nummers: de stream gaat gewoon verder. |
| Het antwoord is een paar minuten geleden klaargekomen | Het 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 verder | Het 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: truebetekent 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
searchenask, beperkt tot de collecties die het publiek van de bot mag zien. - Een
request_idper 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
: pinghem in leven houden. - Antwoorden weergegeven als Markdown, met verwijzingen die naar hun bronnen linken.
- Een duidelijke melding als
emptyis ingesteld, en een manier om een mens te bereiken. 429afgehandeld door opRetry-Afterte wachten.- Beoordelingen die worden teruggestuurd, zodat de kennisbank na verloop van tijd beter wordt.