Ir al contenido
Sursa Developers

Crear un bot de preguntas y respuestas

Esta guía crea la base de un bot que responde a preguntas a partir de su base de conocimiento: en una herramienta de chat, en su centro de ayuda o dentro de su producto. El asistente encuentra el conocimiento necesario y responde, citando las tarjetas que usó; su código envía la pregunta y muestra el resultado.

Necesita una clave con los ámbitos search y ask. La respuesta usa solo las tarjetas que la clave puede leer, así que el rol, el perfil de datos y las colecciones de la clave deciden de qué puede hablar el bot. A un bot para clientes, dele una clave que solo vea colecciones públicas.

Tres formas de obtener una respuesta

EndpointDevuelveÚselo para
POST /v1/searchLos extractos que coinciden, del mejor al peor. Sin respuesta redactada.Un cuadro de búsqueda, «artículos relacionados», su propia clasificación.
POST /v1/askUna respuesta completa con sus fuentes, en una sola respuesta HTTP.Bots que publican un mensaje por respuesta; trabajos del lado del servidor.
POST /v1/chatLa respuesta como un flujo de eventos, mientras se escribe.Interfaces de chat, en las que el usuario debe ver cómo aparece la respuesta.
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"}'

Cada resultado es un extracto de una tarjeta: doc_id y doc_name de la tarjeta, heading de la sección, text del extracto, las páginas o las filas de tabla donde se encontró, y la confianza y la verificación de la tarjeta.

collection selecciona la colección en la que buscar; sin él, se usa la colección llamada default. tags y card_ids restringen la búsqueda a las tarjetas con esas etiquetas o a tarjetas concretas.

Preguntar y obtener una respuesta completa

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

El texto de la respuesta en answer está en Markdown. Sus referencias, como [1] o [1, 3], corresponden al ref de las entradas de sources; enlace cada una con la tarjeta (doc_id) para que el lector pueda comprobar la respuesta.

Una cifra del texto que no se ha podido confirmar con los documentos puede aparecer como un marcador del tipo ⟦n:a1⟧. verification.items describe cada marcador: su marker, el reason y el texto original. Si su código no trata los marcadores, sustituya cada uno por su texto original.

Cuando no hay respuesta

La respuesta HTTP es siempre 200. Cuando el asistente no puede responder, empty indica el motivo, y answer contiene un mensaje breve que puede mostrar:

emptySignificado
no_hitsNada de la base de conocimiento coincide con la pregunta.
restrictedEl tema está en tarjetas que la clave no puede leer.
guardrailLa pregunta queda fuera de lo que la organización permite responder al asistente.
no_data_for_periodLa pregunta trata de un período para el que no hay documentos.

no_hits es una señal útil: muestra lo que preguntan sus usuarios y la base de conocimiento todavía no cubre.

Modos «Fast» y «Precise»

Pase "quality": "precise" para obtener una respuesta más minuciosa a una pregunta difícil, a costa de más tiempo. El valor predeterminado, "fast", sirve para la mayoría de las preguntas.

Transmitir la respuesta

En una interfaz de chat, muestre la respuesta mientras se escribe. POST /v1/chat con "stream": true responde con text/event-stream: un evento por mensaje, cada uno con una línea id: (su número en el flujo, desde 1) y una línea data: con JSON. El campo type del JSON indica qué evento es.

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":[…]}
typeQué hacer
metaLa respuesta ha empezado. Guarde answer_id si registra las respuestas.
sourcesLas tarjetas en las que se basa la respuesta. Puede llegar más de una vez; vale la última.
tokenUn fragmento del texto de la respuesta. Añádalo a lo que muestra.
doneLa respuesta está completa. Tiene los mismos campos que la respuesta de /v1/ask.
errorLa respuesta se ha detenido. El flujo termina sin done. Consulte más abajo.
followupsDespués de done: preguntas siguientes sugeridas que puede ofrecer como botones.
cualquier otroOmítalo. En cualquier momento pueden aparecer tipos de evento nuevos.

Una línea que empieza por : (como : ping, que se envía cada 15 segundos mientras se prepara la respuesta) es un comentario. Omítala.

messages contiene la conversación hasta el momento, de la más antigua a la más reciente, y termina con la pregunta del usuario. Envíe los últimos turnos para que se entiendan las preguntas de seguimiento como «¿y para los pedidos del extranjero?».

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

Sin "stream": true, POST /v1/chat devuelve la respuesta completa como JSON, igual que /v1/ask.

Reanudar un flujo interrumpido

Las conexiones se cortan: cambia la red móvil, un proxy agota su tiempo de espera, su servidor se reinicia. Asigne a cada pregunta un request_id y la respuesta sobrevivirá al corte:

  • Con request_id, la respuesta se prepara con independencia de la conexión. Una desconexión no la detiene: se completa y se guarda.
  • Para reanudarla, vuelva a enviar la misma solicitud, con el mismo request_id y el número del último evento que recibió en el encabezado Last-Event-ID.
  • Use un request_id (por ejemplo, un UUID) por pregunta, y el mismo en cada reintento de esa pregunta. Una pregunta nueva recibe un request_id nuevo.

Lo que recibe la solicitud repetida depende de en qué punto está la respuesta:

SituaciónLa solicitud repetida recibe
La respuesta todavía se está escribiendo o acaba de terminarLos eventos posteriores a Last-Event-ID, con los mismos números: el flujo simplemente continúa.
La respuesta terminó hace unos minutosLa respuesta guardada como un token con el texto completo y un done, numerados desde 1. La pregunta no se vuelve a hacer.
El flujo no puede continuar desde ese númeroLa respuesta desde el principio, numerada desde 1.

Su código trata los tres casos con una sola regla: si el primer evento que recibe tiene un número que no supera Last-Event-ID, la respuesta empieza de nuevo; borre lo que mostró y vuelva a construirla. En caso contrario, añada el texto.

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

La API de Sursa puede funcionar en varios servidores. Un flujo continúa desde Last-Event-ID solo en el servidor que está escribiendo la respuesta; si su solicitud repetida llega a otro servidor, recibe la respuesta guardada una vez terminada, o la respuesta se vuelve a escribir desde el principio. La regla anterior cubre los dos casos, y la pregunta nunca se cuenta dos veces.

Sin request_id, cerrar la conexión cancela la respuesta.

Errores en un flujo

Los errores anteriores al inicio del flujo son errores HTTP normales: 401, 403, 422 o 429 con Retry-After cuando la organización ha agotado su límite de preguntas o tiene demasiadas respuestas en curso a la vez.

Una vez iniciado el flujo, su estado HTTP es siempre 200, y un problema llega como un evento error:

{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
  • retry: true significa que la respuesta se detuvo por causa del servidor, por ejemplo durante una actualización, y puede volver a enviar la misma solicitud sin cambios.
  • Los demás códigos, como timeout, figuran en la referencia de la API. Trate un código desconocido como cualquier otro error del flujo: avise al usuario y déjele volver a preguntar.

Permitir que los usuarios valoren las respuestas

Cada respuesta tiene un question_id. Envíe la valoración del usuario a POST /v1/questions/{question_id}/feedback: 1 si le resultó útil, -1 si no, con un reason y una note opcionales. Las valoraciones ayudan a la organización a ver qué tarjetas necesitan mejoras.

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

Lista de comprobación de un bot en producción

  • Una clave solo con search y ask, limitada a las colecciones que puede ver el público del bot.
  • Un request_id por pregunta y la regla de reanudación anterior.
  • Un tiempo de espera de lectura del flujo superior a 15 segundos, para que los comentarios : ping lo mantengan activo.
  • Respuestas representadas como Markdown, con las referencias enlazadas a sus fuentes.
  • Un mensaje claro cuando empty tiene valor, y una forma de contactar con una persona.
  • Los 429 tratados esperando lo que indica Retry-After.
  • Valoraciones enviadas de vuelta, para que la base de conocimiento mejore con el tiempo.