Vai al contenuto
Sursa Developers

Creare un bot di domande e risposte

Questa guida costruisce il nucleo di un bot che risponde alle domande in base alla tua knowledge base: in uno strumento di chat, nel tuo centro assistenza o all’interno del tuo prodotto. L’assistente trova le conoscenze pertinenti e risponde, citando le schede che ha usato; il tuo codice invia la domanda e mostra il risultato.

Ti serve una chiave con gli ambiti search e ask. La risposta usa solo le schede che la chiave può leggere, quindi ruolo, profilo dati e raccolte della chiave decidono di cosa può parlare il bot. A un bot per i clienti dai una chiave che veda solo le raccolte pubbliche.

Tre modi per ottenere una risposta

EndpointRestituisceUsalo per
POST /v1/searchGli estratti corrispondenti, a partire dal migliore. Nessuna risposta scritta.Una casella di ricerca, “articoli correlati”, un tuo ordinamento.
POST /v1/askUna risposta completa con le sue fonti, in un’unica risposta HTTP.Bot che pubblicano un messaggio per ogni risposta; job di back-end.
POST /v1/chatLa risposta come stream di eventi, mentre viene scritta.Interfacce di chat, dove l’utente deve vedere comparire la risposta.
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"}'

Ogni risultato è un estratto di una scheda: doc_id e doc_name della scheda, heading della sezione, text dell’estratto, le pagine o le righe di tabella in cui è stato trovato, e l’affidabilità e la verifica della scheda.

collection seleziona la raccolta in cui cercare; senza questo parametro si usa la raccolta con il nome default. tags e card_ids restringono la ricerca alle schede con quei tag o a schede specifiche.

Chiedere e ottenere una risposta 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"}'

Il testo della risposta in answer è in Markdown. I suoi riferimenti, come [1] o [1, 3], corrispondono a ref delle voci in sources; collega ciascuno alla scheda (doc_id) così chi legge può verificare la risposta.

Un numero nel testo che non è stato possibile confermare rispetto ai documenti può comparire come un marcatore, per esempio ⟦n:a1⟧. verification.items descrive ogni marcatore: il suo marker, il reason e il testo original. Se il tuo codice non gestisce i marcatori, sostituisci ciascuno con il suo testo original.

Quando non c’è una risposta

La risposta HTTP è sempre 200. Quando l’assistente non può rispondere, empty dice perché, e answer contiene un breve messaggio che puoi mostrare:

emptySignificato
no_hitsNella knowledge base non c’è nulla che corrisponda alla domanda.
restrictedL’argomento si trova in schede che la chiave non può leggere.
guardrailLa domanda va oltre ciò a cui l’organizzazione consente all’assistente di rispondere.
no_data_for_periodLa domanda riguarda un periodo per il quale non ci sono documenti.

no_hits è un segnale utile: mostra cosa chiedono i tuoi utenti che la knowledge base non copre ancora.

Modalità “Fast” e “Precise”

Passa "quality": "precise" (modalità “Precise”, precisa) per una risposta più approfondita a una domanda difficile, a costo di più tempo. Il valore predefinito, "fast" (modalità “Fast”, rapida), è adatto alla maggior parte delle domande.

Trasmettere la risposta in streaming

In un’interfaccia di chat, mostra la risposta mentre viene scritta. POST /v1/chat con "stream": true risponde con text/event-stream: un evento per messaggio, ciascuno con una riga id: (il suo numero nello stream, a partire da 1) e una riga data: con il JSON. Il campo type del JSON dice che evento è.

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":[…]}
typeCosa fare
metaLa risposta è iniziata. Conserva answer_id se registri le risposte.
sourcesLe schede su cui si basa la risposta. Può arrivare più di una volta; vale l’ultimo.
tokenUn pezzo del testo della risposta. Aggiungilo a ciò che mostri.
doneLa risposta è completa. Ha gli stessi campi della risposta di /v1/ask.
errorLa risposta si è interrotta. Lo stream termina senza done. Vedi più avanti.
followupsDopo done: domande successive suggerite, che puoi offrire come pulsanti.
qualsiasi altro valoreIgnoralo. Nuovi tipi di evento possono comparire in qualsiasi momento.

Una riga che inizia con : (come : ping, inviata ogni 15 secondi mentre la risposta viene preparata) è un commento. Ignorala.

messages contiene la conversazione fino a quel momento, dal messaggio più vecchio, e termina con la domanda dell’utente. Invia gli ultimi turni, così le domande successive come “e per gli ordini dall’estero?” vengono capite.

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

Senza "stream": true, POST /v1/chat restituisce la risposta completa in JSON, come /v1/ask.

Riprendere uno stream interrotto

Le connessioni si interrompono: una rete mobile cambia, un proxy va in timeout, il tuo server si riavvia. Assegna a ogni domanda un request_id e la risposta sopravvive all’interruzione:

  • Con request_id, la risposta viene preparata indipendentemente dalla connessione. Una disconnessione non la ferma: viene completata e salvata.
  • Per riprendere, invia di nuovo la stessa richiesta, con lo stesso request_id e il numero dell’ultimo evento ricevuto nell’intestazione Last-Event-ID.
  • Usa un request_id (per esempio un UUID) per ogni domanda, e lo stesso in ogni nuovo tentativo di quella domanda. Una nuova domanda riceve un nuovo request_id.

Cosa riceve la richiesta ripetuta dipende da dove si trova la risposta:

SituazioneLa richiesta ripetuta riceve
La risposta è ancora in fase di scrittura, oppure è appena terminataGli eventi successivi a Last-Event-ID, con gli stessi numeri: lo stream semplicemente continua.
La risposta è terminata da qualche minutoLa risposta salvata come un token con l’intero testo e un done, numerati a partire da 1. La domanda non viene posta di nuovo.
Lo stream non può continuare da quel numeroLa risposta dall’inizio, numerata a partire da 1.

Il tuo codice gestisce tutti e tre i casi con un’unica regola: se il primo evento che ricevi ha un numero non superiore a Last-Event-ID, la risposta ricomincia; cancella ciò che hai mostrato e ricostruiscilo. Altrimenti, aggiungi.

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 di Sursa può funzionare su più server. Uno stream continua da Last-Event-ID solo sul server che sta scrivendo la risposta; se la tua richiesta ripetuta raggiunge un altro server, riceve la risposta salvata quando è terminata, oppure la risposta viene scritta di nuovo dall’inizio. La regola precedente copre entrambi i casi, e la domanda non viene mai conteggiata due volte.

Senza request_id, chiudere la connessione annulla la risposta.

Errori in uno stream

Gli errori prima dell’inizio dello stream sono normali errori HTTP: 401, 403, 422, oppure 429 con Retry-After quando l’organizzazione ha esaurito il suo limite di domande o ha troppe risposte in corso contemporaneamente.

Una volta iniziato lo stream, il suo stato HTTP è sempre 200 e un problema arriva come evento error:

{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
  • retry: true significa che la risposta si è interrotta per una causa del server, per esempio durante un aggiornamento, e puoi inviare di nuovo la stessa richiesta senza modifiche.
  • Gli altri codici, come timeout, sono elencati nel riferimento API. Tratta un codice sconosciuto come qualsiasi altro errore dello stream: avvisa l’utente e lascia che chieda di nuovo.

Permettere agli utenti di valutare le risposte

Ogni risposta ha un question_id. Invia la valutazione dell’utente a POST /v1/questions/{question_id}/feedback: 1 per utile, -1 per non utile, con reason e note facoltativi. Le valutazioni aiutano l’organizzazione a capire quali schede vanno migliorate.

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 per un bot in produzione

  • Una chiave con soli search e ask, limitata alle raccolte che il pubblico del bot può vedere.
  • Un request_id per ogni domanda e la regola di ripresa descritta sopra.
  • Un timeout di lettura dello stream superiore a 15 secondi, così i commenti : ping lo mantengono attivo.
  • Risposte visualizzate come Markdown, con i riferimenti collegati alle loro fonti.
  • Un messaggio chiaro quando empty è impostato e un modo per contattare una persona.
  • 429 gestito attendendo il tempo di Retry-After.
  • Valutazioni inviate a Sursa, così la knowledge base migliora nel tempo.