Pular para o conteúdo
Sursa Developers

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

EndpointRetornaUse para
POST /v1/searchOs 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/askUma resposta completa com as fontes dela, em uma única resposta HTTP.Bots que publicam uma mensagem por resposta; jobs de back-end.
POST /v1/chatA resposta como um stream de eventos, enquanto ela é escrita.Interfaces de chat, em que o usuário deve ver a resposta aparecer.
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:

emptySignificado
no_hitsNada na base de conhecimento corresponde à pergunta.
restrictedO assunto está em cartões que a chave não tem permissão para ler.
guardrailA pergunta está fora do que a organização permite que o assistente responda.
no_data_for_periodA 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":[…]}
typeO que fazer
metaA resposta começou. Guarde answer_id se você registra as respostas.
sourcesOs cartões em que a resposta se baseia. Pode chegar mais de uma vez; vale o último.
tokenUm pedaço do texto da resposta. Acrescente-o ao que você está mostrando.
doneA resposta está completa. Tem os mesmos campos que a resposta de /v1/ask.
errorA resposta parou. O stream termina sem done. Veja abaixo.
followupsDepois de done: sugestões de próximas perguntas que você pode oferecer como botões.
qualquer outroIgnore-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_id e o número do último evento recebido no cabeçalho Last-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 novo request_id.

O que a requisição repetida recebe depende de em que ponto está a resposta:

SituaçãoA requisição repetida recebe
A resposta ainda está sendo escrita ou acabou de terminarOs eventos posteriores a Last-Event-ID, com os mesmos números: o stream simplesmente continua.
A resposta terminou há alguns minutosA 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úmeroA 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: true significa 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 search e ask, limitada às coleções que o público do bot pode ver.
  • Um request_id por pergunta e a regra de retomada acima.
  • Um tempo limite de leitura do stream maior que 15 segundos, para que os comentários : ping o mantenham ativo.
  • Respostas exibidas como Markdown, com as referências ligadas às fontes delas.
  • Uma mensagem clara quando empty estiver preenchido e uma forma de falar com uma pessoa.
  • 429 tratado aguardando o Retry-After.
  • Avaliações enviadas de volta, para que a base de conhecimento melhore com o tempo.