Créer un bot de questions-réponses
Ce guide construit le cœur d’un bot qui répond aux questions à partir de votre base de connaissances : dans un outil de messagerie, dans votre centre d’aide ou au sein de votre produit. L’assistant trouve les connaissances utiles et répond en citant les fiches qu’il a utilisées ; votre code envoie la question et affiche le résultat.
Il vous faut une clé dotée des portées search et ask. La réponse n’utilise que les
fiches que la clé a le droit de lire : le rôle, le profil de données et les collections de la clé déterminent donc les
sujets que le bot peut aborder. Donnez à un bot destiné aux clients une clé qui ne voit que les collections publiques.
Trois façons d’obtenir une réponse
| Point de terminaison | Renvoie | À utiliser pour |
|---|---|---|
POST /v1/search | Les extraits correspondants, les meilleurs d’abord. Pas de réponse rédigée. | Un champ de recherche, des « articles liés », votre propre classement. |
POST /v1/ask | Une réponse complète avec ses sources, en une seule réponse HTTP. | Les bots qui publient un message par réponse ; les jobs côté serveur. |
POST /v1/chat | La réponse sous forme de flux d’événements, au fil de sa rédaction. | Les interfaces de chat, où l’utilisateur doit voir la réponse apparaître. |
Rechercher
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"}'Chaque résultat est un extrait d’une fiche : doc_id et doc_name de la fiche, heading de la section, text de
l’extrait, les pages ou les lignes de tableau où il a été trouvé, ainsi que la confiance et la validation de la fiche.
collection choisit la collection dans laquelle rechercher ; sans ce paramètre, la collection nommée default est
utilisée. tags et card_ids limitent la recherche aux fiches qui portent ces étiquettes ou à certaines fiches.
Poser une question et obtenir une réponse complète
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"}'Le texte de la réponse, dans answer, est en Markdown. Ses références, comme [1] ou [1, 3], correspondent au ref
des entrées de sources ; liez chacune à la fiche (doc_id) pour que le lecteur puisse vérifier la réponse.
Un nombre du texte qui n’a pas pu être confirmé par les documents peut apparaître sous la forme d’un marqueur comme
⟦n:a1⟧. verification.items décrit chaque marqueur : son marker, le motif reason et le texte original. Si votre
code ne traite pas les marqueurs, remplacez chacun par son texte original.
Quand il n’y a pas de réponse
La réponse est toujours 200. Quand l’assistant ne peut pas répondre, empty indique pourquoi, et answer contient
un court message que vous pouvez afficher :
empty | Signification |
|---|---|
no_hits | Rien dans la base de connaissances ne correspond à la question. |
restricted | Le sujet se trouve dans des fiches que la clé n’a pas le droit de lire. |
guardrail | La question sort de ce que l’organisation autorise l’assistant à traiter. |
no_data_for_period | La question porte sur une période pour laquelle il n’existe aucun document. |
no_hits est un signal utile : il montre ce que vos utilisateurs demandent et que la base de connaissances ne couvre
pas encore.
Modes « Fast » et « Precise »
Passez "quality": "precise" pour obtenir une réponse plus approfondie à une question difficile, au prix d’un temps de
réponse plus long. La valeur par défaut, "fast", convient à la plupart des questions.
Diffuser la réponse en flux
Dans une interface de chat, affichez la réponse au fil de sa rédaction. POST /v1/chat
avec "stream": true répond en text/event-stream : un événement par message, chacun avec une ligne id: (son
numéro dans le flux, à partir de 1) et une ligne data: contenant du JSON. Le champ type du JSON indique de quel
événement il s’agit.
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 | Que faire |
|---|---|
meta | La réponse a commencé. Conservez answer_id si vous journalisez les réponses. |
sources | Les fiches sur lesquelles repose la réponse. Peut arriver plusieurs fois ; le dernier l’emporte. |
token | Un morceau du texte de la réponse. Ajoutez-le à ce que vous affichez. |
done | La réponse est complète. Il a les mêmes champs que la réponse de /v1/ask. |
error | La réponse s’est arrêtée. Le flux se termine sans done. Voir ci-dessous. |
followups | Après done : des questions suivantes suggérées, à proposer sous forme de boutons. |
| tout autre type | Ignorez-le. De nouveaux types d’événements peuvent apparaître à tout moment. |
Une ligne qui commence par : (comme : ping, envoyée toutes les 15 secondes pendant la préparation de la réponse) est
un commentaire. Ignorez-la.
messages contient la conversation jusqu’ici, du plus ancien au plus récent, et se termine par la question de
l’utilisateur. Envoyez les derniers échanges pour que les questions de suivi comme « et pour les commandes depuis
l’étranger ? » soient comprises.
// 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;
}Sans "stream": true, POST /v1/chat renvoie la réponse complète en JSON, comme /v1/ask.
Reprendre un flux interrompu
Les connexions se coupent : un réseau mobile change, un proxy dépasse son délai, votre serveur redémarre. Donnez à
chaque question un request_id et la réponse survit à la coupure :
- Avec
request_id, la réponse est préparée indépendamment de la connexion. Une déconnexion ne l’arrête pas : elle est terminée et enregistrée. - Pour reprendre, renvoyez la même requête, avec le même
request_id, et le numéro du dernier événement reçu dans l’en-têteLast-Event-ID. - Utilisez un
request_id(par exemple un UUID) par question, et le même pour chaque nouvelle tentative de cette question. Une nouvelle question reçoit un nouveaurequest_id.
Ce que reçoit la requête répétée dépend de l’état de la réponse :
| Situation | La requête répétée reçoit |
|---|---|
| La réponse est encore en cours de rédaction, ou vient de se terminer | Les événements qui suivent Last-Event-ID, avec les mêmes numéros : le flux continue simplement. |
| La réponse s’est terminée il y a quelques minutes | La réponse enregistrée, sous forme d’un token contenant tout le texte et d’un done, numérotés à partir de 1. La question n’est pas posée une seconde fois. |
| Le flux ne peut pas reprendre à partir de ce numéro | La réponse depuis le début, numérotée à partir de 1. |
Votre code traite les trois cas avec une seule règle : si le premier événement reçu porte un numéro inférieur ou égal
à Last-Event-ID, la réponse recommence ; effacez ce que vous avez affiché et reconstruisez-le. Sinon, ajoutez à la
suite.
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));
}
}
}L’API Sursa peut fonctionner sur plusieurs serveurs. Un flux ne reprend à partir de Last-Event-ID que sur le serveur
qui rédige la réponse ; si votre requête répétée arrive sur un autre serveur, elle reçoit la réponse enregistrée une
fois celle-ci terminée, ou la réponse est rédigée de nouveau depuis le début. La règle ci-dessus couvre les deux cas,
et la question n’est jamais comptée deux fois.
Sans request_id, fermer la connexion annule la réponse.
Erreurs dans un flux
Les erreurs qui surviennent avant le début du flux sont des erreurs HTTP ordinaires : 401, 403, 422, ou 429
avec Retry-After quand l’organisation a atteint sa limite de questions ou a trop de
réponses en cours en même temps.
Une fois le flux commencé, son statut HTTP est toujours 200, et un problème arrive sous la forme d’un événement
error :
{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
retry: truesignifie que la réponse s’est arrêtée à cause du serveur, par exemple pendant une mise à jour : vous pouvez renvoyer la même requête sans la modifier.- Les autres codes, comme
timeout, sont listés dans la référence de l’API. Traitez un code inconnu comme n’importe quelle autre erreur de flux : prévenez l’utilisateur et laissez-le poser de nouveau sa question.
Permettre aux utilisateurs de noter les réponses
Chaque réponse a un question_id. Envoyez la note de l’utilisateur à
POST /v1/questions/{question_id}/feedback : 1 pour utile, -1 pour pas
utile, avec un reason et une note facultatifs. Les notes aident l’organisation à voir quelles fiches demandent du
travail.
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."}'Liste de contrôle pour un bot en production
- Une clé dotée uniquement de
searchetask, limitée aux collections que le public du bot peut voir. - Un
request_idpar question, et la règle de reprise ci-dessus. - Un délai de lecture du flux supérieur à 15 secondes, pour que les commentaires
: pingle maintiennent actif. - Des réponses affichées en Markdown, avec des références liées à leurs sources.
- Un message clair quand
emptyest renseigné, et un moyen de joindre une personne. - Les
429traités en attendant la durée deRetry-After. - Les notes renvoyées, pour que la base de connaissances s’améliore avec le temps.