Criar um bot de perguntas e respostas
Este guia monta o núcleo de um bot que responde a perguntas com base na sua base de conhecimento: em uma ferramenta de chat, na sua central de ajuda ou dentro do seu produto. O assistente encontra o conhecimento relevante e responde, citando os cartões que usou; o seu código envia a pergunta e mostra o resultado.
Você precisa de uma chave com os escopos search e ask. A resposta usa só os cartões
que a chave tem permissão para ler, então a função, o perfil de dados e as coleções da chave decidem sobre o que o bot
pode falar. Dê a um bot para clientes uma chave que só veja coleções públicas.
Três formas de obter uma resposta
| Endpoint | Retorna | Use para |
|---|---|---|
POST /v1/search | Os trechos que correspondem, os melhores primeiro. Sem resposta escrita. | Uma caixa de busca, “artigos relacionados”, a sua própria ordenação dos resultados. |
POST /v1/ask | Uma resposta completa com as fontes dela, em uma única resposta HTTP. | Bots que publicam uma mensagem por resposta; jobs de back-end. |
POST /v1/chat | A resposta como um stream de eventos, enquanto ela é escrita. | Interfaces de chat, em que o usuário deve ver a resposta aparecer. |
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 é um trecho de um cartão: doc_id e doc_name do cartão, heading da seção, text do trecho, as
páginas ou as linhas de tabela em que ele foi encontrado, e a confiança e a verificação do cartão.
collection seleciona a coleção em que buscar; sem ele, é usada a coleção chamada default. tags e card_ids
restringem a busca aos cartões com essas etiquetas ou a cartões específicos.
Perguntar e receber uma resposta 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"}'O texto da resposta em answer está em Markdown. As referências dele, como [1] ou [1, 3], correspondem ao ref
das entradas de sources; ligue cada uma ao cartão (doc_id) para que o leitor possa conferir a resposta.
Um número do texto que não pôde ser confirmado nos documentos pode aparecer como um marcador, como ⟦n:a1⟧.
verification.items descreve cada marcador: o marker dele, o reason e o texto original. Se o seu código não
trata marcadores, substitua cada um pelo texto original dele.
Quando não há resposta
A resposta HTTP é sempre 200. Quando o assistente não consegue responder, empty diz por quê, e answer traz uma
mensagem curta que você pode mostrar:
empty | Significado |
|---|---|
no_hits | Nada na base de conhecimento corresponde à pergunta. |
restricted | O assunto está em cartões que a chave não tem permissão para ler. |
guardrail | A pergunta está fora do que a organização permite que o assistente responda. |
no_data_for_period | A pergunta trata de um período para o qual não há documentos. |
no_hits é um sinal útil: ele mostra o que os seus usuários perguntam e a base de conhecimento ainda não cobre.
Modos “Fast” (rápido) e “Precise” (preciso)
Passe "quality": "precise" para obter uma resposta mais minuciosa a uma pergunta difícil, ao custo de mais tempo. O
padrão, "fast", atende à maioria das perguntas.
Transmitir a resposta
Em uma interface de chat, mostre a resposta enquanto ela é escrita. POST /v1/chat com
"stream": true responde com text/event-stream: um evento por mensagem, cada um com uma linha id: (o número dele
no stream, a partir de 1) e uma linha data: com JSON. O campo type do JSON diz que evento é esse.
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 | O que fazer |
|---|---|
meta | A resposta começou. Guarde answer_id se você registra as respostas. |
sources | Os cartões em que a resposta se baseia. Pode chegar mais de uma vez; vale o último. |
token | Um pedaço do texto da resposta. Acrescente-o ao que você está mostrando. |
done | A resposta está completa. Tem os mesmos campos que a resposta de /v1/ask. |
error | A resposta parou. O stream termina sem done. Veja abaixo. |
followups | Depois de done: sugestões de próximas perguntas que você pode oferecer como botões. |
| qualquer outro | Ignore-o. Novos tipos de evento podem aparecer a qualquer momento. |
Uma linha que começa com : (como : ping, enviada a cada 15 segundos enquanto a resposta está sendo preparada) é um
comentário. Ignore-a.
messages traz a conversa até aqui, das mensagens mais antigas para as mais novas, terminando com a pergunta do
usuário. Envie os últimos turnos para que perguntas de acompanhamento, como “e para pedidos do exterior?”, sejam
entendidas.
// 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;
}Sem "stream": true, POST /v1/chat retorna a resposta completa em JSON, como /v1/ask.
Retomar um stream interrompido
As conexões caem: a rede móvel muda, um proxy atinge o tempo limite, o seu servidor reinicia. Dê a cada pergunta um
request_id, e a resposta sobrevive à queda:
- Com
request_id, a resposta é preparada independentemente da conexão. Uma desconexão não a interrompe: ela é concluída e salva. - Para retomar, envie a mesma requisição de novo, com o mesmo
request_ide o número do último evento recebido no cabeçalhoLast-Event-ID. - Use um
request_id(por exemplo, um UUID) por pergunta e o mesmo valor em todas as novas tentativas dessa pergunta. Uma pergunta nova recebe um novorequest_id.
O que a requisição repetida recebe depende de em que ponto está a resposta:
| Situação | A requisição repetida recebe |
|---|---|
| A resposta ainda está sendo escrita ou acabou de terminar | Os eventos posteriores a Last-Event-ID, com os mesmos números: o stream simplesmente continua. |
| A resposta terminou há alguns minutos | A resposta salva, como um token com o texto inteiro e um done, numerados a partir de 1. A pergunta não é feita de novo. |
| O stream não consegue continuar a partir desse número | A resposta desde o início, numerada a partir de 1. |
O seu código trata os três casos com uma única regra: se o primeiro evento recebido tiver um número menor ou igual a
Last-Event-ID, a resposta recomeça; apague o que você mostrou e monte tudo de novo. Caso contrário, acrescente.
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));
}
}
}A API do Sursa pode funcionar em vários servidores. Um stream só continua a partir de Last-Event-ID no servidor que
está escrevendo a resposta; se a sua requisição repetida chegar a outro servidor, ela recebe a resposta salva assim
que estiver pronta, ou a resposta é escrita de novo desde o início. A regra acima cobre os dois casos, e a pergunta
nunca é contada duas vezes.
Sem request_id, fechar a conexão cancela a resposta.
Erros em um stream
Os erros antes do início do stream são erros HTTP comuns: 401, 403, 422 ou 429 com Retry-After, quando a
organização esgotou o limite de perguntas ou tem
respostas em andamento demais ao mesmo tempo.
Depois que o stream começou, o status HTTP dele é sempre 200, e um problema chega como um evento error:
{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
retry: truesignifica que a resposta parou por causa do servidor, por exemplo durante uma atualização, e você pode enviar a mesma requisição de novo sem mudanças.- Os outros códigos, como
timeout, estão listados na referência da API. Trate um código desconhecido como qualquer outro erro do stream: avise o usuário e deixe que ele pergunte de novo.
Permitir que os usuários avaliem as respostas
Toda resposta tem um question_id. Envie a avaliação do usuário para
POST /v1/questions/{question_id}/feedback: 1 para útil, -1 para não
útil, com reason e note opcionais. As avaliações ajudam a organização a ver quais cartões precisam de melhorias.
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 de um bot em produção
- Uma chave só com
searcheask, limitada às coleções que o público do bot pode ver. - Um
request_idpor pergunta e a regra de retomada acima. - Um tempo limite de leitura do stream maior que 15 segundos, para que os comentários
: pingo mantenham ativo. - Respostas exibidas como Markdown, com as referências ligadas às fontes delas.
- Uma mensagem clara quando
emptyestiver preenchido e uma forma de falar com uma pessoa. 429tratado aguardando oRetry-After.- Avaliações enviadas de volta, para que a base de conhecimento melhore com o tempo.