Gå til indholdet
Sursa Developers

Byg en Q&A-bot

I denne vejledning bygger du kernen i en bot, der besvarer spørgsmål ud fra din vidensbase: i et chatværktøj, i dit hjælpecenter eller i dit produkt. Assistenten finder den relevante viden og svarer med henvisning til de kort, den har brugt; din kode sender spørgsmålet og viser resultatet.

Du skal bruge en nøgle med scopes search og ask. Svaret bruger kun de kort, som nøglen har lov til at læse, så nøglens rolle, dataprofil og samlinger bestemmer, hvad botten kan tale om. Giv en bot til kunder en nøgle, der kun ser offentlige samlinger.

Tre måder at få et svar på

OperationReturnererBrug den til
POST /v1/searchDe uddrag, der matcher, bedste først. Intet skrevet svar.Et søgefelt, »relaterede artikler«, din egen rangering.
POST /v1/askEt komplet svar med kilder i ét HTTP-svar.Bots, der sender én besked pr. svar; baggrundsjob.
POST /v1/chatSvaret som en stream af hændelser, mens det skrives.Chatgrænseflader, hvor brugeren skal se svaret dukke op.
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"}'

Hvert hit er et uddrag af et kort: kortets doc_id og doc_name, afsnittets heading, uddragets text, de sider eller tabelrækker, hvor det blev fundet, samt kortets tillidsscore og verificeringsstatus.

collection vælger den samling, der skal søges i; uden den bruges samlingen med navnet default. tags og card_ids begrænser søgningen til kort med disse tags eller til bestemte kort.

Spørg, og få et komplet svar

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

Svarets tekst i answer er Markdown. Dens henvisninger, fx [1] eller [1, 3], svarer til ref i posterne i sources; link hver af dem til kortet (doc_id), så læseren kan kontrollere svaret.

Et tal i teksten, som ikke kunne bekræftes i dokumenterne, kan stå som en markør som ⟦n:a1⟧. verification.items beskriver hver markør: dens marker, årsagen reason og teksten original. Håndterer din kode ikke markører, så erstat hver af dem med dens tekst fra original.

Når der ikke er noget svar

HTTP-svaret er altid 200. Når assistenten ikke kan svare, fortæller empty hvorfor, og answer indeholder en kort besked, som du kan vise:

emptyBetydning
no_hitsIntet i vidensbasen matcher spørgsmålet.
restrictedEmnet findes i kort, som nøglen ikke har lov til at læse.
guardrailSpørgsmålet ligger uden for det, som organisationen tillader assistenten at besvare.
no_data_for_periodSpørgsmålet handler om en periode, som der ikke findes dokumenter for.

no_hits er et nyttigt signal: Det viser, hvad dine brugere spørger om, som vidensbasen endnu ikke dækker.

»Fast« og »Precise«

Angiv "quality": "precise" for at få et grundigere svar på et svært spørgsmål, men det tager længere tid. Standardværdien, "fast", passer til de fleste spørgsmål.

Stream svaret

I en chatgrænseflade viser du svaret, mens det skrives. POST /v1/chat med "stream": true svarer med text/event-stream: én hændelse pr. besked, hver med en id:-linje (dens nummer i streamen, fra 1) og en data:-linje med JSON. JSON’ens felt type fortæller, hvilken hændelse det er.

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":[…]}
typeHvad du gør
metaSvaret er startet. Gem answer_id, hvis du logger svar.
sourcesDe kort, svaret bygger på. Kan komme mere end én gang; den sidste gælder.
tokenEt stykke af svarets tekst. Føj det til det, du viser.
doneSvaret er færdigt. Hændelsen har de samme felter som svaret fra /v1/ask.
errorSvaret stoppede. Streamen slutter uden done. Se nedenfor.
followupsEfter done: foreslåede næste spørgsmål, som du kan tilbyde som knapper.
alt andetSpring det over. Nye hændelsestyper kan dukke op når som helst.

En linje, der begynder med : (fx : ping, som sendes hvert 15. sekund, mens svaret forberedes), er en kommentar. Spring den over.

messages indeholder samtalen indtil nu, ældste først, og slutter med brugerens spørgsmål. Send de seneste runder, så opfølgende spørgsmål som »og for ordrer fra udlandet?« bliver forstået.

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

Uden "stream": true returnerer POST /v1/chat det komplette svar som JSON ligesom /v1/ask.

Genoptag en afbrudt stream

Forbindelser afbrydes: Et mobilnetværk skifter, en proxy får timeout, din server genstarter. Giv hvert spørgsmål et request_id, så overlever svaret afbrydelsen:

  • Med request_id forberedes svaret uafhængigt af forbindelsen. En afbrydelse stopper det ikke: Det gøres færdigt og gemmes.
  • For at genoptage sender du den samme anmodning igen med det samme request_id og nummeret på den sidste hændelse, du modtog, i headeren Last-Event-ID.
  • Brug ét request_id (fx et UUID) pr. spørgsmål og det samme ved hvert nyt forsøg med det spørgsmål. Et nyt spørgsmål får et nyt request_id.

Hvad den gentagne anmodning får, afhænger af, hvor langt svaret er:

SituationDen gentagne anmodning modtager
Svaret er stadig ved at blive skrevet eller er lige blevet færdigtHændelserne efter Last-Event-ID med de samme numre: Streamen fortsætter blot.
Svaret blev færdigt for nogle minutter sidenDet gemte svar som en token med hele teksten og en done, nummereret fra 1. Spørgsmålet stilles ikke igen.
Streamen kan ikke fortsætte fra det nummerSvaret fra begyndelsen, nummereret fra 1.

Din kode håndterer alle tre tilfælde med én regel: Har den første hændelse, du modtager, et nummer, der ikke er større end Last-Event-ID, starter svaret forfra; ryd det, du har vist, og byg det op igen. Ellers føjer du til.

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

Sursas API kan køre på flere servere. En stream fortsætter kun fra Last-Event-ID på den server, der skriver svaret; rammer din gentagne anmodning en anden server, modtager den det gemte svar, når det er færdigt, eller svaret skrives igen fra begyndelsen. Reglen ovenfor dækker begge tilfælde, og spørgsmålet tælles aldrig to gange.

Uden request_id annulleres svaret, når forbindelsen lukkes.

Fejl i en stream

Fejl, før streamen starter, er almindelige HTTP-fejl: 401, 403, 422 eller 429 med Retry-After, når organisationen har brugt sin grænse for spørgsmål eller har for mange svar i gang på samme tid.

Når streamen er startet, er dens HTTP-status altid 200, og et problem kommer som en error-hændelse:

{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
  • retry: true betyder, at svaret stoppede på grund af serveren, fx under en opdatering, og at du kan sende den samme anmodning igen uændret.
  • Andre koder, fx timeout, står i API-referencen. Behandl en ukendt kode som enhver anden fejl i streamen: Fortæl brugeren det, og lad vedkommende spørge igen.

Lad brugerne vurdere svar

Hvert svar har et question_id. Send brugerens vurdering til POST /v1/questions/{question_id}/feedback: 1 for nyttigt, -1 for ikke nyttigt, med valgfri reason og note. Vurderingerne hjælper organisationen med at se, hvilke kort der skal arbejdes på.

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

Tjekliste for en bot i produktion

  • En nøgle med kun search og ask, begrænset til de samlinger, som bottens målgruppe må se.
  • Et request_id pr. spørgsmål og reglen for genoptagelse ovenfor.
  • En timeout for læsning af streamen, der er længere end 15 sekunder, så kommentarerne : ping holder den i live.
  • Svar, der vises som Markdown, med henvisninger linket til deres kilder.
  • En tydelig besked, når empty er sat, og en mulighed for at komme i kontakt med en person.
  • 429 håndteret ved at vente det antal sekunder, der står i Retry-After.
  • Vurderinger, der sendes tilbage, så vidensbasen bliver bedre over tid.