Chuyển tới nội dung
Sursa Developers

Xây dựng bot hỏi đáp

Hướng dẫn này xây dựng phần lõi của một bot trả lời câu hỏi từ cơ sở tri thức của bạn: trong công cụ trò chuyện, trong trung tâm trợ giúp, hoặc bên trong sản phẩm của bạn. Trợ lý tìm tri thức cần thiết và trả lời, kèm trích dẫn các thẻ đã dùng; mã của bạn gửi câu hỏi và hiển thị kết quả.

Bạn cần một khóa có phạm vi quyền search và ask. Câu trả lời chỉ dùng những thẻ mà khóa được phép đọc, nên vai trò, hồ sơ dữ liệu và bộ sưu tập của khóa quyết định bot có thể nói về điều gì. Hãy cấp cho bot dành cho khách hàng một khóa chỉ nhìn thấy các bộ sưu tập công khai.

Ba cách nhận câu trả lời

EndpointTrả vềDùng cho
POST /v1/searchCác đoạn văn khớp, khớp nhất trước. Không có câu trả lời được viết ra.Ô tìm kiếm, “bài viết liên quan”, cách xếp hạng của riêng bạn.
POST /v1/askCâu trả lời đầy đủ kèm nguồn, trong một phản hồi.Bot đăng một tin nhắn cho mỗi câu trả lời; tác vụ phía máy chủ.
POST /v1/chatCâu trả lời dưới dạng luồng sự kiện, trong khi nó được viết.Giao diện trò chuyện, nơi người dùng cần thấy câu trả lời hiện ra dần.
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"}'

Mỗi kết quả khớp là một đoạn văn của thẻ: doc_id và doc_name của thẻ, heading của phần, text của đoạn văn, các trang hoặc hàng trong bảng nơi đoạn văn được tìm thấy, cùng độ tin cậy và trạng thái kiểm chứng của thẻ.

collection chọn bộ sưu tập để tìm kiếm; khi không có, bộ sưu tập có tên default được dùng. tags và card_ids thu hẹp tìm kiếm về các thẻ có những nhãn đó hoặc về các thẻ cụ thể.

Hỏi và nhận câu trả lời đầy đủ

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

Nội dung câu trả lời trong answer là Markdown. Các tham chiếu trong đó, chẳng hạn [1] hoặc [1, 3], khớp với ref của các mục trong sources; hãy liên kết mỗi tham chiếu tới thẻ (doc_id) để người đọc có thể kiểm tra câu trả lời.

Một con số trong văn bản chưa thể xác nhận được với tài liệu có thể xuất hiện dưới dạng dấu đánh dấu như ⟦n:a1⟧. verification.items mô tả từng dấu đánh dấu: marker của dấu, lý do reason và văn bản original. Nếu mã của bạn không xử lý dấu đánh dấu, hãy thay mỗi dấu bằng văn bản original của nó.

Khi không có câu trả lời

Phản hồi luôn là 200. Khi trợ lý không thể trả lời, empty cho biết lý do, và answer chứa một thông báo ngắn mà bạn có thể hiển thị:

emptyÝ nghĩa
no_hitsKhông có gì trong cơ sở tri thức khớp với câu hỏi.
restrictedChủ đề nằm trong những thẻ mà khóa không được phép đọc.
guardrailCâu hỏi nằm ngoài những gì tổ chức cho phép trợ lý trả lời.
no_data_for_periodCâu hỏi hỏi về một khoảng thời gian không có tài liệu nào.

no_hits là một tín hiệu hữu ích: nó cho thấy người dùng của bạn hỏi những gì mà cơ sở tri thức chưa bao quát.

Chế độ Fast và Precise

Truyền "quality": "precise" để nhận câu trả lời kỹ lưỡng hơn cho câu hỏi khó, đổi lại cần nhiều thời gian hơn. Giá trị mặc định, "fast", phù hợp với hầu hết câu hỏi.

Truyền câu trả lời theo luồng

Trong giao diện trò chuyện, hãy hiển thị câu trả lời trong khi nó được viết. POST /v1/chat với "stream": true phản hồi bằng text/event-stream: một sự kiện cho mỗi thông điệp, mỗi sự kiện có một dòng id: (số thứ tự của nó trong luồng, bắt đầu từ 1) và một dòng data: chứa JSON. Trường type của JSON cho biết đó là sự kiện gì.

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":[…]}
typeCần làm gì
metaCâu trả lời đã bắt đầu. Giữ answer_id nếu bạn ghi nhật ký câu trả lời.
sourcesCác thẻ mà câu trả lời dựa vào. Có thể tới nhiều lần; lần cuối cùng có hiệu lực.
tokenMột phần nội dung câu trả lời. Nối nó vào phần bạn đang hiển thị.
doneCâu trả lời đã hoàn tất. Sự kiện có cùng các trường như phản hồi của /v1/ask.
errorCâu trả lời đã dừng. Luồng kết thúc mà không có done. Xem bên dưới.
followupsSau done: các câu hỏi tiếp theo được gợi ý mà bạn có thể đưa ra dưới dạng nút.
bất kỳ loại nào khácBỏ qua. Loại sự kiện mới có thể xuất hiện bất cứ lúc nào.

Dòng bắt đầu bằng : (chẳng hạn : ping, được gửi 15 giây một lần trong khi câu trả lời đang được chuẩn bị) là chú thích. Hãy bỏ qua nó.

messages chứa cuộc trò chuyện cho tới lúc đó, cũ nhất trước, kết thúc bằng câu hỏi của người dùng. Hãy gửi vài lượt gần nhất để các câu hỏi tiếp nối như “còn đơn hàng từ nước ngoài thì sao?” được hiểu đúng.

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

Khi không có "stream": true, POST /v1/chat trả về câu trả lời đầy đủ dưới dạng JSON, giống như /v1/ask.

Tiếp tục luồng bị ngắt

Kết nối có thể bị ngắt: mạng di động thay đổi, proxy hết thời gian chờ, máy chủ của bạn khởi động lại. Hãy gán cho mỗi câu hỏi một request_id và câu trả lời sẽ vượt qua được sự cố ngắt kết nối:

  • Với request_id, câu trả lời được chuẩn bị độc lập với kết nối. Việc ngắt kết nối không làm nó dừng lại: câu trả lời được hoàn tất và lưu lại.
  • Để tiếp tục, hãy gửi lại cùng yêu cầu đó, với cùng request_id, và số của sự kiện cuối cùng bạn đã nhận trong header Last-Event-ID.
  • Dùng một request_id (ví dụ một UUID) cho mỗi câu hỏi, và dùng chính nó trong mọi lần thử lại của câu hỏi đó. Câu hỏi mới nhận request_id mới.

Yêu cầu lặp lại nhận được gì phụ thuộc vào tình trạng của câu trả lời:

Tình huốngYêu cầu lặp lại nhận được
Câu trả lời vẫn đang được viết, hoặc vừa mới xongCác sự kiện sau Last-Event-ID, với cùng số thứ tự: luồng chỉ đơn giản tiếp tục.
Câu trả lời đã xong cách đây vài phútCâu trả lời đã lưu dưới dạng một token chứa toàn bộ văn bản và một done, đánh số từ 1. Câu hỏi không được hỏi lại.
Luồng không thể tiếp tục từ số đóCâu trả lời từ đầu, đánh số từ 1.

Mã của bạn xử lý cả ba trường hợp bằng một quy tắc: nếu sự kiện đầu tiên bạn nhận có số không lớn hơn Last-Event-ID, câu trả lời bắt đầu lại từ đầu; hãy xóa những gì đã hiển thị và dựng lại. Ngược lại, hãy nối tiếp.

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

Sursa API có thể chạy trên nhiều máy chủ. Luồng chỉ tiếp tục từ Last-Event-ID trên máy chủ đang viết câu trả lời; nếu yêu cầu lặp lại của bạn tới một máy chủ khác, nó nhận câu trả lời đã lưu khi câu trả lời đã xong, hoặc câu trả lời được viết lại từ đầu. Quy tắc trên bao quát cả hai trường hợp, và câu hỏi không bao giờ bị tính hai lần.

Khi không có request_id, việc đóng kết nối sẽ hủy câu trả lời.

Lỗi trong luồng

Lỗi trước khi luồng bắt đầu là lỗi HTTP thông thường: 401, 403, 422, hoặc 429 với Retry-After khi tổ chức đã dùng hết giới hạn câu hỏi hoặc có quá nhiều câu trả lời đang được tạo cùng lúc.

Khi luồng đã bắt đầu, trạng thái HTTP của nó luôn là 200, và sự cố được báo bằng một sự kiện error:

{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
  • retry: true nghĩa là câu trả lời dừng vì phía máy chủ, ví dụ trong lúc cập nhật, và bạn có thể gửi lại nguyên yêu cầu đó.
  • Các mã khác, chẳng hạn timeout, được liệt kê trong tài liệu tham chiếu API. Hãy xử lý mã không xác định như mọi lỗi luồng khác: báo cho người dùng và để họ hỏi lại.

Cho người dùng đánh giá câu trả lời

Mỗi câu trả lời có một question_id. Hãy gửi đánh giá của người dùng tới POST /v1/questions/{question_id}/feedback: 1 là hữu ích, -1 là không hữu ích, kèm reason và note nếu muốn. Đánh giá giúp tổ chức thấy những thẻ nào cần được cải thiện.

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

Danh sách kiểm tra cho bot chạy thực tế

  • Một khóa chỉ có search và ask, giới hạn trong các bộ sưu tập mà người dùng của bot được xem.
  • Một request_id cho mỗi câu hỏi, và quy tắc tiếp tục luồng ở trên.
  • Thời gian chờ khi đọc luồng dài hơn 15 giây, để các chú thích : ping giữ cho luồng không bị ngắt.
  • Câu trả lời được hiển thị dưới dạng Markdown, với các tham chiếu liên kết tới nguồn của chúng.
  • Thông báo rõ ràng khi empty được đặt, và một cách để liên hệ với người thật.
  • 429 được xử lý bằng cách chờ hết Retry-After.
  • Gửi lại đánh giá, để cơ sở tri thức ngày càng tốt hơn.