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
| Endpoint | Devuelve | Úselo para |
|---|---|---|
POST /v1/search | Los extractos que coinciden, del mejor al peor. Sin respuesta redactada. | Un cuadro de búsqueda, «artículos relacionados», su propia clasificación. |
POST /v1/ask | Una respuesta completa con sus fuentes, en una sola respuesta HTTP. | Bots que publican un mensaje por respuesta; trabajos del lado del servidor. |
POST /v1/chat | La respuesta como un flujo de eventos, mientras se escribe. | Interfaces de chat, en las que el usuario debe ver cómo aparece la respuesta. |
Buscar
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:
empty | Significado |
|---|---|
no_hits | Nada de la base de conocimiento coincide con la pregunta. |
restricted | El tema está en tarjetas que la clave no puede leer. |
guardrail | La pregunta queda fuera de lo que la organización permite responder al asistente. |
no_data_for_period | La 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":[…]}
type | Qué hacer |
|---|---|
meta | La respuesta ha empezado. Guarde answer_id si registra las respuestas. |
sources | Las tarjetas en las que se basa la respuesta. Puede llegar más de una vez; vale la última. |
token | Un fragmento del texto de la respuesta. Añádalo a lo que muestra. |
done | La respuesta está completa. Tiene los mismos campos que la respuesta de /v1/ask. |
error | La respuesta se ha detenido. El flujo termina sin done. Consulte más abajo. |
followups | Después de done: preguntas siguientes sugeridas que puede ofrecer como botones. |
| cualquier otro | Omí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_idy el número del último evento que recibió en el encabezadoLast-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 unrequest_idnuevo.
Lo que recibe la solicitud repetida depende de en qué punto está la respuesta:
| Situación | La solicitud repetida recibe |
|---|---|
| La respuesta todavía se está escribiendo o acaba de terminar | Los eventos posteriores a Last-Event-ID, con los mismos números: el flujo simplemente continúa. |
| La respuesta terminó hace unos minutos | La 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úmero | La 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: truesignifica 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
searchyask, limitada a las colecciones que puede ver el público del bot. - Un
request_idpor pregunta y la regla de reanudación anterior. - Un tiempo de espera de lectura del flujo superior a 15 segundos, para que los comentarios
: pinglo mantengan activo. - Respuestas representadas como Markdown, con las referencias enlazadas a sus fuentes.
- Un mensaje claro cuando
emptytiene valor, y una forma de contactar con una persona. - Los
429tratados esperando lo que indicaRetry-After. - Valoraciones enviadas de vuelta, para que la base de conocimiento mejore con el tiempo.