الوصف
عند بث رد من Gemini، تعالج Discourse AI في بعض الأحيان الجزء الأول فقط من الرد. يتم استلام الرد الكامل من Google وتخزينه في ai_api_audit_logs.raw_response_payload، وينتهي البث مع finishReason بقيمة STOP، ولا يتم تسجيل أي خطأ. يستقبل المستدعي النص حتى حدث معين ولا شيء بعده. في حالة الترجمة بالذكاء الاصطناعي، يتم تخزين النص المقطوع كترجمة منتهية.
تم ملاحظة ذلك على موقع ذاتي الاستضافة يعمل بإصدار release/2026.7 (2026.7.3) مع إضافة discourse-ai المرفقة وgemini-3.8-flash على مستوى الخدمة القياسي. في 3 من حوالي 26,200 استدعاء ترجمة على مدار ثلاثة أيام، كانت قيمة response_tokens في سجل التدقيق أقل من candidatesTokenCount النهائي الذي أبلغ عنه Google في نفس الرد: 196 من 241، و84 من 328، و131 من 179. في كل حالة، تساوي القيمة المخزنة القيمة التراكمية لـ Google عند حدث وسيط واحد، مما يعني أن فك الترميز توقف بعد ذلك الحدث.
هذا منفصل عن أحداث الأخطاء داخل البث التي تم إصلاحها في #44262: لا يوجد حدث خطأ هنا، ولم يتغير فك الترميز بسبب طلب السحب (pull request) المذكور.
السبب الجذري
يقوم DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder#decode بتقسيم مخزنه المؤقت (buffer) على /\r?\n\r?\n/ ويقبل المقطع فقط إذا بدأ بـ data: {.
عندما ينتهي مقطع الشبكة (network chunk) داخل السطر الفارغ الذي يفصل بين حدثين، على سبيل المثال بعد أول \r\n من \r\n\r\n، يتم تحليل الحدث السابق ويُفرَّغ المخزن المؤقت. ثم يبدأ المقطع التالي بالـ \r\n المتبقي، لذا يصبح المقطع التالي "\r\ndata: {...}". بما أنه لا يبدأ بـ data: {، يتم إبقاؤه في المخزن المؤقت كسطر غير مكتمل. يتم إضافة كل الأحداث اللاحقة إلى ذلك المخزن المؤقت ولا يتم التعرف عليها أيضًا. ينتهي البث بشكل طبيعي وتُتجاهل الأحداث المتبقية مع المخزن المؤقت.
يحدث الشيء نفسه مع الفواصل التي تعتمد على LF فقط عندما ينتهي المقطع بين سطرَي تغذية.
فك الترميز متطابق في v2026.7.3 وفي main عند b5548f76 (2026-10-05).
إعادة الإنتاج
في وحدة تحكم Rails:
decoder = DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder.new
event = ->(text, tokens) { %(data: {"candidates": [{"content": {"parts": [{"text": "#{text}"}],"role": "model"}}],"usageMetadata": {"candidatesTokenCount": #{tokens}}}) }
chunks = [
event.("A", 11) + "\r\n\r\n",
event.("B", 38) + "\r\n",
"\r\n" + event.("C", 65) + "\r\n\r\n",
event.("D", 95) + "\r\n\r\n",
]
chunks.flat_map { |chunk| decoder.decode(chunk) }.map { |e| e.dig(:candidates, 0, :content, :parts, 0, :text) }
النتيجة: ["A", "B"]. المتوقع: ["A", "B", "C", "D"]. نقل حد المقطع إلى أي نقطة خارج الفاصل، بما في ذلك منتصف الحدث، يعطي النتيجة المتوقعة.
الإصلاح المقترح
تجاهل علامات تبديل السطر في بداية المقطع قبل اختباره، على سبيل المثال:
line = line.sub(/\A[\r\n]+/, "")
if line.start_with?("data: {")
مع هذا التغيير، تعيد إعادة الإنتاج المذكورة أعلاه جميع الأحداث الأربعة، وكذلك الحدود بعد \r\n، وبعد \r\n\r، وداخل حدث، ومع فواصل LF فقط.
تحديد الاستدعاءات المتأثرة
كل صف تُرجعه هذه الاستعلام هو استدعاء أكمله Google بشكل طبيعي وقامت Discourse بفك ترميزه جزئيًا فقط:
SELECT l.id, l.created_at, l.post_id, l.topic_id, l.response_tokens AS stored_tokens, g.sent_tokens
FROM ai_api_audit_logs l
CROSS JOIN LATERAL (
SELECT MAX((m)[1]::int) AS sent_tokens
FROM regexp_matches(l.raw_response_payload, '"candidatesTokenCount":\s*(\d+)', 'g') AS m
) g
WHERE l.language_model LIKE 'gemini%'
AND l.raw_response_payload ~ '"finishReason":\s*"STOP"'
AND g.sent_tokens <> l.response_tokens
ORDER BY l.created_at
الأثر
يعتمد الخلل فقط على أماكن حدود مقاطع الشبكة، لذا فهو ليس خاصًا بالترجمة أو بمستوى الخدمة؛ يمكن لأي إكمال Gemini مُبث أن يفقد نهايته بهذه الطريقة. في حالة الترجمة، يكون النتيجة منشورًا أو عنوانًا مُختصرًا بصمت لا تعيد عملية الملء الخلفي (backfill) معالجته، لأن صف الترجمة (localization row) موجود.