انتقل إلى المحتوى
Sursa Developers

بناء روبوت أسئلة وأجوبة

يبني هذا الدليل جوهر روبوت يجيب عن الأسئلة من قاعدة معرفتك: في أداة محادثة، أو في مركز المساعدة لديك، أو داخل منتجك. يجد المساعد المعرفة المطلوبة ويجيب، مستشهدًا بالبطاقات التي استخدمها؛ وشيفرتك ترسل السؤال وتعرض النتيجة.

تحتاج إلى مفتاح له نطاقا الصلاحيات search وask. لا تستخدم الإجابة إلا البطاقات التي يُسمح للمفتاح بقراءتها، فدور المفتاح وملف تعريف البيانات والمجموعات تقرر ما يستطيع الروبوت الحديث عنه. وأعطِ روبوت العملاء مفتاحًا لا يرى إلا المجموعات العامة.

ثلاث طرق للحصول على إجابة

نقطة النهايةما تعيدهمتى تستخدمها
POST /v1/searchالمقاطع المطابقة، الأفضل أولًا. بلا إجابة مكتوبة.مربع بحث، أو «مقالات ذات صلة»، أو ترتيبك الخاص للنتائج.
POST /v1/askإجابة كاملة مع مصادرها، في استجابة واحدة.الروبوتات التي تنشر رسالة واحدة لكل إجابة؛ ومهام الخادم الخلفية.
POST /v1/chatالإجابة على شكل تدفق من الأحداث أثناء كتابتها.واجهات المحادثة، حيث ينبغي أن يرى المستخدم الإجابة وهي تظهر.
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"}'

كل نتيجة مقطع من بطاقة: doc_id وdoc_name للبطاقة، وheading للقسم، وtext للمقطع، والصفحات أو صفوف الجدول التي وُجد فيها، وثقة البطاقة وتوثيقها.

يختار المعامل collection المجموعة التي يجري فيها البحث؛ ومن دونه تُستخدم المجموعة التي اسمها default. ويضيّق المعاملان tags وcard_ids البحث إلى البطاقات التي تحمل تلك الوسوم أو إلى بطاقات بعينها.

السؤال والحصول على إجابة كاملة

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

نص الإجابة في answer بصيغة Markdown. وإحالاته، مثل [1] أو [1, 3]، تطابق ref في إدخالات sources؛ اربط كل إحالة بالبطاقة (doc_id) كي يتمكن القارئ من التحقق من الإجابة.

قد يظهر في النص رقم تعذّر تأكيده من المستندات على شكل علامة مثل ⟦n:a1⟧. ويصف الحقل verification.items كل علامة: قيمتها marker، والسبب reason، والنص original. وإن كانت شيفرتك لا تعالج العلامات، فضع مكان كل علامة نصها من الحقل original.

حين لا توجد إجابة

الاستجابة دائمًا بالحالة 200. وحين لا يستطيع المساعد الإجابة، يبيّن الحقل empty السبب، ويحمل answer رسالة قصيرة يمكنك عرضها:

emptyالمعنى
no_hitsلا شيء في قاعدة المعرفة يطابق السؤال.
restrictedالموضوع موجود في بطاقات لا يُسمح للمفتاح بقراءتها.
guardrailالسؤال خارج ما تسمح المؤسسة للمساعد بالإجابة عنه.
no_data_for_periodالسؤال عن فترة لا توجد عنها مستندات.

القيمة no_hits إشارة مفيدة: فهي تُظهر ما يسأل عنه مستخدموك ولا تغطيه قاعدة المعرفة بعد.

الوضعان Fast وPrecise

مرّر "quality": "precise" (دقيق) للحصول على إجابة أكثر تعمقًا عن سؤال صعب، مقابل وقت أطول. والقيمة الافتراضية "fast" (سريع) تناسب معظم الأسئلة.

عرض الإجابة تدفقًا

في واجهة المحادثة، اعرض الإجابة أثناء كتابتها. تجيب العملية POST /v1/chat مع "stream": true بالصيغة text/event-stream: حدث واحد لكل رسالة، ولكل حدث سطر id: (رقمه في التدفق، بدءًا من 1) وسطر data: يحمل JSON. ويبيّن الحقل type في JSON نوع الحدث.

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ما العمل
metaبدأت الإجابة. احفظ answer_id إن كنت تسجّل الإجابات.
sourcesالبطاقات التي تستند إليها الإجابة. قد يصل أكثر من مرة، والأخير هو المعتمد.
tokenجزء من نص الإجابة. ألحقه بما تعرضه.
doneاكتملت الإجابة. وله الحقول نفسها التي في استجابة /v1/ask.
errorتوقفت الإجابة. ينتهي التدفق بلا done. انظر أدناه.
followupsبعد done: أسئلة تالية مقترحة يمكنك عرضها أزرارًا.
أي نوع آخرتجاهله. قد تظهر أنواع أحداث جديدة في أي وقت.

السطر الذي يبدأ بالرمز : (مثل : ping، الذي يُرسَل كل 15 ثانية أثناء إعداد الإجابة) تعليق. تجاهله.

يحمل الحقل messages المحادثة حتى الآن، الأقدم أولًا، وتنتهي بسؤال المستخدم. أرسل المداخلات الأخيرة القليلة كي تُفهم أسئلة المتابعة مثل «وماذا عن الطلبات من الخارج؟».

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

بلا "stream": true، تعيد العملية POST /v1/chat الإجابة كاملة بصيغة JSON، مثل /v1/ask.

استئناف تدفق منقطع

الاتصالات تنقطع: تتغيّر شبكة الهاتف المحمول، أو تنتهي مهلة خادم وكيل، أو يُعاد تشغيل خادمك. أعطِ كل سؤال قيمة request_id، فتنجو الإجابة من الانقطاع:

  • مع request_id تُعَدّ الإجابة باستقلال عن الاتصال. فالانقطاع لا يوقفها، بل تكتمل وتُحفظ.
  • للاستئناف، أرسل الطلب نفسه مرة أخرى، بقيمة request_id نفسها، ورقم آخر حدث تلقيته في الترويسة Last-Event-ID.
  • استخدم قيمة request_id واحدة (مثل UUID) لكل سؤال، والقيمة نفسها في كل إعادة محاولة لذلك السؤال. والسؤال الجديد يحصل على request_id جديد.

ما يتلقاه الطلب المكرر يعتمد على المرحلة التي بلغتها الإجابة:

الحالةما يتلقاه الطلب المكرر
الإجابة لا تزال قيد الكتابة، أو انتهت للتوالأحداث بعد Last-Event-ID، بالأرقام نفسها: يستمر التدفق ببساطة.
انتهت الإجابة قبل بضع دقائقالإجابة المحفوظة على شكل حدث token بالنص كله وحدث done، مرقّمين بدءًا من 1. ولا يُطرح السؤال مرة أخرى.
لا يمكن أن يستمر التدفق من ذلك الرقمالإجابة من البداية، مرقّمة بدءًا من 1.

تعالج شيفرتك الحالات الثلاث بقاعدة واحدة: إذا كان رقم أول حدث تتلقاه لا يزيد على Last-Event-ID، فالإجابة تبدأ من جديد؛ امسح ما عرضته وابنِه مرة أخرى. وإلا فألحق ما يصل.

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 على عدة خوادم. ولا يستمر التدفق من Last-Event-ID إلا على الخادم الذي يكتب الإجابة؛ فإن وصل طلبك المكرر إلى خادم آخر، فإنه يتلقى الإجابة المحفوظة بعد اكتمالها، أو تُكتب الإجابة من جديد من البداية. والقاعدة أعلاه تغطي الحالتين، ولا يُحتسب السؤال مرتين أبدًا.

بلا request_id، يلغي إغلاق الاتصال الإجابة.

الأخطاء في التدفق

الأخطاء قبل بدء التدفق أخطاء HTTP عادية: 401، أو 403، أو 422، أو 429 مع Retry-After حين تستنفد المؤسسة حد الأسئلة أو يكون لديها عدد كبير جدًا من الإجابات الجارية في وقت واحد.

وبعد أن يبدأ التدفق، تكون حالة HTTP دائمًا 200، وتصل المشكلة على شكل حدث error:

{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
  • القيمة retry: true تعني أن الإجابة توقفت بسبب الخادم، أثناء تحديث مثلًا، ويمكنك إرسال الطلب نفسه مرة أخرى دون تغيير.
  • الرموز الأخرى، مثل timeout، مذكورة في مرجع API. تعامل مع الرمز المجهول كأي خطأ آخر في التدفق: أخبر المستخدم، ودعه يسأل مرة أخرى.

تقييم المستخدمين للإجابات

لكل إجابة قيمة question_id. أرسل تقييم المستخدم إلى POST /v1/questions/{question_id}/feedback: القيمة 1 للإجابة المفيدة، و-1 لغير المفيدة، مع reason وnote اختياريين. وتساعد التقييمات المؤسسة على معرفة البطاقات التي تحتاج إلى تحسين.

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

قائمة تحقق لروبوت في بيئة الإنتاج

  • مفتاح له search وask فقط، مقصور على المجموعات التي يحق لجمهور الروبوت رؤيتها.
  • قيمة request_id لكل سؤال، وقاعدة الاستئناف أعلاه.
  • مهلة لقراءة التدفق أطول من 15 ثانية، كي تبقيه تعليقات : ping حيًا.
  • إجابات تُعرض بصيغة Markdown، وإحالات مربوطة بمصادرها.
  • رسالة واضحة حين يُملأ الحقل empty، وطريقة للوصول إلى شخص.
  • معالجة الحالة 429 بانتظار Retry-After.
  • تقييمات تُرسَل، كي تتحسن قاعدة المعرفة مع الوقت.