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
| Endpoint | Trả về | Dùng cho |
|---|---|---|
POST /v1/search | Cá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/ask | Câ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/chat | Câ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. |
Tìm kiếm
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_hits | Không có gì trong cơ sở tri thức khớp với câu hỏi. |
restricted | Chủ đề nằm trong những thẻ mà khóa không được phép đọc. |
guardrail | Câu hỏi nằm ngoài những gì tổ chức cho phép trợ lý trả lời. |
no_data_for_period | Câ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":[…]}
type | Cần làm gì |
|---|---|
meta | Câu trả lời đã bắt đầu. Giữ answer_id nếu bạn ghi nhật ký câu trả lời. |
sources | Cá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. |
token | Một phần nội dung câu trả lời. Nối nó vào phần bạn đang hiển thị. |
done | Câ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. |
error | Câu trả lời đã dừng. Luồng kết thúc mà không có done. Xem bên dưới. |
followups | Sau 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ác | Bỏ 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 headerLast-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ậnrequest_idmớ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ống | Yê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 xong | Cá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út | Câ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: truenghĩ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ó
searchvà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_idcho 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
: pinggiữ 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ếtRetry-After.- Gửi lại đánh giá, để cơ sở tri thức ngày càng tốt hơn.