Aller au contenu
Sursa Developers

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 terminaisonRenvoieÀ utiliser pour
POST /v1/searchLes 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/askUne 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/chatLa 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.
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 :

emptySignification
no_hitsRien dans la base de connaissances ne correspond à la question.
restrictedLe sujet se trouve dans des fiches que la clé n’a pas le droit de lire.
guardrailLa question sort de ce que l’organisation autorise l’assistant à traiter.
no_data_for_periodLa 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":[…]}
typeQue faire
metaLa réponse a commencé. Conservez answer_id si vous journalisez les réponses.
sourcesLes fiches sur lesquelles repose la réponse. Peut arriver plusieurs fois ; le dernier l’emporte.
tokenUn morceau du texte de la réponse. Ajoutez-le à ce que vous affichez.
doneLa réponse est complète. Il a les mêmes champs que la réponse de /v1/ask.
errorLa réponse s’est arrêtée. Le flux se termine sans done. Voir ci-dessous.
followupsAprès done : des questions suivantes suggérées, à proposer sous forme de boutons.
tout autre typeIgnorez-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ête Last-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 nouveau request_id.

Ce que reçoit la requête répétée dépend de l’état de la réponse :

SituationLa requête répétée reçoit
La réponse est encore en cours de rédaction, ou vient de se terminerLes é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 minutesLa 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éroLa 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: true signifie 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 search et ask, limitée aux collections que le public du bot peut voir.
  • Un request_id par question, et la règle de reprise ci-dessus.
  • Un délai de lecture du flux supérieur à 15 secondes, pour que les commentaires : ping le maintiennent actif.
  • Des réponses affichées en Markdown, avec des références liées à leurs sources.
  • Un message clair quand empty est renseigné, et un moyen de joindre une personne.
  • Les 429 traités en attendant la durée de Retry-After.
  • Les notes renvoyées, pour que la base de connaissances s’améliore avec le temps.