fix: keep agent streams alive during long tasks

This commit is contained in:
Hamza Ayed
2026-10-04 13:41:22 +03:00
parent ad221e24aa
commit d1010b79d7
11 changed files with 334 additions and 37 deletions
+7 -3
View File
@@ -4,6 +4,10 @@
## الحالة الحالية — 2026-10-04
- 2026-10-04 — إصلاح بقاء اتصال بث الوكيل: ظهر أن SSE لا يرسل أي بايت أثناء انتظار استجابة نموذج بطيئة، مع أن مهلة Flutter عشر دقائق؛ أضيف تعليق `: keep-alive` كل 15 ثانية عند خمول البث. اختبار حتمي يحاكي انتظار النموذج وإلغاء العميل، ومجموعة `test_timeouts_and_cancellation.py` نجحت 4/4. أُعيد تشغيل API على 8000 من الشفرة الحالية؛ `/health` أعاد 200، وطلب وكيل حي اختار الحاسبة وأعاد `5.0`، وظهر تعليقان keep-alive قبل اكتمال الرد ثم نجح تسجيل الخروج. هذا يعالج إغلاق الاتصالات الخاملة على المسارات الوسيطة، ولا يثبت سرعة النموذج نفسه.
- 2026-10-04 — إصلاح بطء فهرسة PDF الممسوح الذي كان يوقف حلقة FastAPI: نقل تحليل الصفحات والرسم وOCR إلى worker threads؛ صار فهرس Flutter يرسل ملفًا واحدًا في كل مرة بمهلة 5 دقائق ويعرض اسم الملف ورقمه. أثناء OCR حي استغرق 155.21 ثانية، وظل `/health` يستجيب خلال 0.02 ثانية، واكتمل OCR للصفحة وفهرستها دلاليًا. اجتاز اختبار PDF المختلط 1/1؛ واجتازت Flutter 22/22 و`flutter analyze --no-pub` بلا ملاحظات، واختبارات مهلة الوكيل 4/4. لم يكتمل بناء Windows Debug بهذه التغييرات في هذه الجولة: نسخة التطوير المؤقتة تعطلت بسبب مسار تضمين `flutter_secure_storage_windows` في MSBuild/الرابط الرمزي؛ لم يتغير كاش الحزمة أو مصدرها.
- 2026-10-04 — تدقيق حالة «العمل على مشروع»: تأكد وجود إنشاء مجلد جديد أو تسجيل مجلد قائم من Flutter، وربطه بحساب المستخدم في SQLite، وفتح الملفات والبحث فيها واقتراح إنشاء/تعديل ملف عبر diff وموافقة صريحة. التطبيق وFastAPI/Gemma 4/SQLite يعملون محليًا الآن. الوكيل لا يشغّل بعد أوامر البناء أو الاختبارات؛ prototype AppContainer غير مدمج، وحد التخزين الصلب لم يثبت، لذا التنفيذ الآلي للمشروع ليس مدعومًا. في اختبار الحالتين على API التطبيق وجد الاسترجاع الدليل 2/2 لكن Gemma امتنعت مرة بصياغة عربية جديدة؛ أضيفت الصياغة لاختبار fallback، واختبارات مهارات الوكيل 21/21. أُعيد تشغيل API التطبيق على 8000 من الشفرة الحالية مع قاعدة SQLite نفسها؛ `/health` أكد Gemma 4 وSQLite وبقاء ملف قاعدة المحادثات. تحقق الاختبار الحي من السؤالين: الأول عرض مقتطف `migration.md` مع مصدره، والثاني ذكر الحقول الثلاثة؛ ثم أزال فهرس الاختبار وتسجيل المشروع والجلسة (`evals/results/retrieval_live_api_fix_2026-10-04.json`). التفاصيل في `evals/MODEL_REVIEW_2026-10.md`.
- 2026-10-04 — تسجيل المشاريع في الواجهة والـAPI: عند اختيار مجلد من Flutter يُسجّل API المحلي المسار في SQLite تحت هوية الحساب (`user_workspace_roots`) ثم يسرد الملفات المدعومة؛ عند إزالة المشروع يُلغى التسجيل ولا تُحذف الملفات. أضيف POST/DELETE لـ`/v1/agent/projects` ويُسمح بهما عبر loopback فقط. اختبارات API تثبت التسجيل والقراءة لصاحب المشروع، رفض الحساب الآخر، منع تسجيل مجلد متداخل بين حسابين أو مع جذر خصصه مسؤول لحساب آخر (409)، وإلغاء الوصول بعد الحذف أو بعد حذف المجلد الأصلي. اختبار حي على مجلد خارج جذر المنتج قرأ `README.md`، وسأل Gemma عبر الوكيل فأجاب من المحتوى باسم `Cedar`، ثم تحقق رفض الوصول بعد الإلغاء. اختبارات Python ذات الصلة 10/10 وFlutter 17/17، و`flutter analyze` بلا ملاحظات و`compileall` ناجح. أُعيد تشغيل API وفحص `/health` (`ok`, Gemma 4)، وتأكد وجود مساري POST/DELETE في OpenAPI، وبُني وشُغّل Windows Debug الحالي (PID 1328). تشغيل أوامر البناء/الاختبار من الوكيل لم يُدمج بعد؛ عزل نظام التشغيل لهذا التشغيل ما زال مفتوحًا.
@@ -77,7 +81,7 @@
- [x] مسار تحليل ملفات مرفقة نصية/برمجية وPDF عبر FastAPI، مع تحقق النوع والحجم وحد أقصى 3 ملفات. الملفات النصية حتى 256KB لكل ملف؛ PDF حتى 8MB لكل ملف، ومجموع المرفقات 16MB. PDF الرقمي حتى 30 صفحة و24 ألف محرف؛ PDF بلا طبقة نصية يحول أول 3 صفحات إلى JPEG محليًا ويرسلها إلى نموذج الرؤية المحلي. لا حفظ على القرص ولا تشغيل للكود. اجتاز اختبار API حقيقي على صورة عربية/إنجليزية، وأربعة اختبارات PDF.
- [x] توحيد أخطاء HTTP والتحقق وإضافة `X-Request-ID` وربطه برسائل Flutter؛ الاختبارات الحية والوحدوية أكدت 200 و404 و422 ومعرّف الاستجابة وتنقية تفاصيل التحقق (2026-10-02).
- [x] اختبارات تكامل لعقد API لحالات الصحة والأخطاء والقدرات، مع التحقق من استمرار اختبارات SQLite ومساحة العمل (14 اختبار Python ناجح، 2026-10-02).
- [x] اختبار مهلة مزوّد النموذج وحد الطلب وإغلاق العميل عند الإلغاء، وإلغاء مهمة الوكيل عند إغلاق بث SSE؛ تظهر مهلة النموذج 504 (2026-10-02: 14 اختبار Python ناجح).
- [x] اختبار مهلة مزوّد النموذج وحد الطلب وإغلاق العميل عند الإلغاء، وإلغاء مهمة الوكيل عند إغلاق بث SSE؛ تظهر مهلة النموذج 504. (2026-10-02: 14 اختبار Python ناجح؛ 2026-10-04 أضيفت نبضات SSE كل 15 ثانية، واختبارات الوحدة الأربع وطلب وكيل حي تحققت من بقاء الاتصال حتى `done`.)
- إبقاء الخدمة محلية افتراضيًا؛ لا تُعرض على الشبكة قبل مصادقة المستخدم ومراجعة إعدادات الأمان.
- [x] حفظ عنوان API والنموذج والإشعارات في مخزن إعدادات محلي للأجهزة والويب؛ تعرض الواجهة فشل التخزين الدائم ولا توهم المستخدم بالحفظ. (2026-10-02: استعادة Cubit بعد إعادة إنشائه، إنشاء الملف على Windows، بناء الويب والتحليل واختبارات Flutter ناجحة.)
- [x] إعداد تفضيل المظهر الداكن وحفظه محليًا؛ يبدّل سمة التطبيق وأسطح المحادثة الأساسية، واختبار واجهة على نافذة صغيرة.
@@ -153,7 +157,7 @@
- [x] قياس إضافي على 9 أسئلة عن ملفات الكود الفعلية (`workspace.py`, `knowledge.py`, `pdf_documents.py`, `main.py`). كشف التقييم أن تكرار مقاطع الملف الواحد يزاحم مصادر أخرى؛ حدّثنا البحث لجلب مجموعة أوسع ثم توزيع حتى مقطعين لكل ملف. بعد التعديل: Hit@1=0.667، Hit@3=1.0، MRR=0.833، وظهور الدليل=1.0. النتائج في `retrieval_2026-10-02_141132.json` (قبل التعديل) و`retrieval_2026-10-02_141337.json` (بعده). العينة صغيرة واختبارها معجمي؛ الدقة الدلالية وجودة الإجابة لم تُقاسا.
- [x] جولة تحسين استرجاع المقاطع العميقة على 11 سؤالًا من المجموعة المحلية (2026-10-03): توسيع محدود لمرادفات صلاحيات المهارات بالعربية والإنجليزية، ورفع سقف النتائج من 5 إلى 8 مع إبقاء حد 4 مقاطع لكل ملف لحماية تنوع المصادر. على API محلي تجريبي بقاعدة معزولة: `Hit@1=1.0`, `Hit@3=1.0`, `MRR=1.0`, وظهور الدليل `11/11`؛ تقرير `evals/results/retrieval_2026-10-03_160000.json`. اجتازت اختبارات قاعدة المعرفة `12/12`.
- [x] توسيع الحزمة وإضافة جواب الوكيل من الأدلة المفهرسة (2026-10-03): أضيف دليل تقني بطول 15.7 كيلوبايت فيه المعلومة المستهدفة في المقطع 17 وPDF نصي؛ حفظ المشغّل اختياريًا إجابات الوكيل ومصادرها للمراجعة دون منح درجة آلية. الجولة الأولى على 13 حالة أعادت `Hit@1=1.0`, `Hit@3=1.0`, `MRR=1.0`, وظهور الدليل `13/13` (`evals/results/retrieval_extended_2026-10-03.json`). بعد إصلاح وزن OCR المحلي المعطوب وإعادة تشغيله دون تنزيل، أضيف PDF ممسوح فعلي إلى المجموعة؛ على API التطبيق الحالي بقاعدة SQLite الحية المؤقتة ثم حذف عناصرها بعد القياس: `Hit@1=1.0`, `Hit@3=1.0`, `MRR=1.0`, وظهور الدليل `14/14` (`evals/results/retrieval_extended_14_2026-10-03.json`). جواب الوكيل على PDF الممسوح استخرج الموعد `17 October 2026` وذكر مصدره (`evals/results/retrieval_scanned_pdf_agent_2026-10-03.json`). تجربة EasyOCR المباشرة والـAPI استخدمتا أوزانًا محلية فقط؛ النسخة التالفة محفوظة احتياطيًا بجانب الوزن المُصلح.
- [ ] متابعة التقييم على ملفات طويلة وPDFات متنوعة وأسئلة جديدة، وجمع مراجعة مستقلة لجودة إجابات النموذج فوق السياق المسترجع. النتائج الحالية fixtures صغيرة ومصطنعة؛ الدرجات تصف هذه الحالات ولا تثبت جودة عامة. في 2026-10-04 جُمعت 14 إجابة محفوظة من المجموعة ذات 11 حالة ومجموعتي PDF الطويل/الممسوح في ورقة مراجعة `evals/HUMAN_REVIEW_2026-10.md` مع سلم موحد وخانات فارغة. أضيفت مقتطفات المرجع بجانب كل إجابة؛ نص PDF الممسوح استُخرج محليًا بـEasyOCR (ثقة 0.591). مراجعة Codex الآلية في `evals/MODEL_REVIEW_2026-10.md` أعطت خط أساس 48/56 وكشفت امتناعين رغم وجود الدليل. بعد إصلاح الامتناع أُعيد تشغيل الحالتين على API 8000؛ ظهرت الأدلة المطلوبة 2/2، وأضيفت الإجابات الجديدة إلى ورقة المراجعة بجانب baseline القديم لئلا يختلط تقييم النسختين. لا يُعد هذا إعادة تقييم للمجموعة كاملة؛ المراجعة البشرية المستقلة ودرجات الحالات الأربع عشرة ما زالت مطلوبة.
- [ ] متابعة التقييم على ملفات طويلة وPDFات متنوعة وأسئلة جديدة، وجمع مراجعة مستقلة لجودة إجابات النموذج فوق السياق المسترجع. النتائج الحالية fixtures صغيرة ومصطنعة؛ الدرجات تصف هذه الحالات ولا تثبت جودة عامة. في 2026-10-04 جُمعت 14 إجابة محفوظة من المجموعة ذات 11 حالة ومجموعتي PDF الطويل/الممسوح في ورقة مراجعة `evals/HUMAN_REVIEW_2026-10.md` مع سلم موحد وخانات فارغة. أضيفت مقتطفات المرجع بجانب كل إجابة؛ نص PDF الممسوح استُخرج محليًا بـEasyOCR (ثقة 0.591). مراجعة Codex الآلية في `evals/MODEL_REVIEW_2026-10.md` أعطت خط أساس 48/56 وكشفت امتناعين رغم وجود الدليل. بعد إصلاح الامتناع أُعيد تشغيل الحالتين على API 8000؛ ظهرت الأدلة المطلوبة 2/2، وأضيفت الإجابات الجديدة إلى ورقة المراجعة بجانب baseline القديم لئلا يختلط تقييم النسختين. لا يُعد هذا إعادة تقييم للمجموعة كاملة؛ المراجعة البشرية المستقلة ودرجات الحالات الأربع عشرة ما زالت مطلوبة. خلال محاولة إعادة التقييم الموسعة 2026-10-04 تجاوز طلب الفهرسة المجمع مهلة 180 ثانية بسبب تضمينات 14 ملفًا ومعالجة PDF؛ نظّف المشغّل الفهرس وتسجيل المشروع. أضيف `--workspace-dir` لمجلد اختبار فارغ ظاهر مع حذف الملفات ببصمة المحتوى، وصار يفهرس كل ملف بطلب منفصل لتقليل زمن الطلب الواحد. تجربة حالة نصية مفردة نجحت 1/1 (`evals/results/retrieval_workspace_smoke_2026-10-04.json`). إعادة الجولة الموسعة بعد التغيير ومراجعة بشرية مستقلة ما زالتا مطلوبتين.
- [x] مهارات محلية أولية قابلة للاختيار من واجهة وضع الوكيل: شرح الكود، مراجعة الكود، وخطة اختبارات. تعرض `/v1/agent/skills` وصف كل مهارة وأدواتها؛ تُحقن التعليمات الموثوقة في سياق الوكيل وتُفلتر قائمة الأدوات، ويرفض الخادم استدعاء أداة خارج صلاحيات المهارة. لمهارة مراجعة الكود معاينة فقط ولا تطبيق مباشر. اجتازت اختبارات الصلاحيات والواجهة؛ يبقى تقييم دقة كل مهارة على أمثلة أكثر قبل اعتمادها افتراضيًا.
- [x] قراءة رابط عام محدد عبر `POST /v1/web/read`: استخراج نص HTML الثابت وتمريره إلى Gemma المحلية للإجابة مع إرجاع الرابط والعنوان.
- [x] بحث ويب متعدد المصادر تجريبي عبر DuckDuckGo بلا مفتاح API: اختيار نطاقات مختلفة، محاولة جلب الصفحات العامة، تلخيص بالنموذج المحلي مع روابط المصادر، ووسم المقتطفات عند تعذر فتح الصفحة. (2026-10-01: استجابة حية أعادت ملخصًا ومصدرين مختلفين عبر FastAPI/Gemma.) أضيف زمن جلب إجمالي وزمن لكل مصدر إلى الاستجابة لمساعدة تشخيص المصادر البطيئة؛ اختبارات الحجب ومزوّد رسمي اختياري ما زالت لاحقًا.
@@ -206,7 +210,7 @@
3. [x] حالة النموذج والمدة والخطأ وإيقاف التوليد (2026-10-01): اختبار إلغاء الرد الجزئي وإلغاء إعادة التوليد قبل أول رمز مع الحفاظ على الإجابة السابقة، واختبار زر الإيقاف بالواجهة؛ `flutter analyze` و5 اختبارات Flutter ناجحة، وWindows Debug يعمل بعد hot restart.
4. [x] إضافة طبقة مزود النموذج واكتشاف النماذج (2026-10-01): عقد موحد للطلب الكامل والبث والقائمة، واستخدامه في المحادثة والوكيل وقراءة مساحة العمل والويب؛ Ollama هو المزوّد المنفذ حاليًا. تحقق حي من `/health` و`/v1/models` وطلب محادثة باستخدام Gemma.
5. [x] إكمال Markdown للمرحلة 1: جداول قابلة للتمرير، قوائم متداخلة، ومربعات مهام GFM؛ التحليل واختبارات الواجهة الثلاثة ناجحة وبناء Windows Debug نجح.
6. [x] تشغيل جلسة Windows Debug بعد إصلاح مجلدي روابط إضافات `record` المؤقتة. بناء OneDrive يفشل عند cloud placeholders وMSBuild `FTK1011`. في 2026-10-04 نجح بناء وتشغيل نسخة `%TEMP%` (PID 33624) مع API/Gemma؛ ثم أضيف `scripts/run_windows_debug_local.ps1` لمسار ثابت خارج OneDrive. `-BuildOnly` في `%LOCALAPPDATA%\SovereignAI-Starter\windows-dev\flutter_app` نجح أول مرة في 336 ثانية، وإعادة البناء بالـcache نجحت في 55 ثانية. وفي 2026-10-04 شُغلت أيضًا نسخة Debug الأحدث التي تتضمن زر إنشاء المشروع (PID 35400) وأعادت API `/health` حالة `ok` مع Gemma 4 وSQLite والوكيل.
6. [x] تشغيل جلسة Windows Debug بعد إصلاح مجلدي روابط إضافات `record` المؤقتة. بناء OneDrive يفشل عند cloud placeholders وMSBuild `FTK1011`. في 2026-10-04 نجح بناء وتشغيل نسخة `%TEMP%` (PID 33624) مع API/Gemma؛ ثم أضيف `scripts/run_windows_debug_local.ps1` لمسار ثابت خارج OneDrive. `-BuildOnly` في `%LOCALAPPDATA%\SovereignAI-Starter\windows-dev\flutter_app` نجح أول مرة في 336 ثانية، وإعادة البناء بالـcache نجحت في 55 ثانية. وفي 2026-10-04 شُغلت أيضًا نسخة Debug الأحدث التي تتضمن زر إنشاء المشروع (PID 35400) وأعادت API `/health` حالة `ok` مع Gemma 4 وSQLite والوكيل. في محاولة 2026-10-04 لبناء تغييرات heartbeat/فهرسة PDF لم يكتمل البناء: إضافة `flutter_secure_storage_windows` لم تجد الرأس C++ عبر مسار الرابط الرمزي؛ ترجمت الإضافة منفردة بعد توجيه MSBuild إلى مسار الكاش الحقيقي، لكن البناء الكامل اصطدم بصلاحيات سجلات MSBuild في النسخة المؤقتة. لذلك يجب إعادة البناء والتحقق من نافذة Windows عند إصلاح بيئة البناء؛ لا تُحسب هذه المحاولة تشغيلًا ناجحًا.
- [ ] تحقق مرئي ووظيفي كامل من زر فتح/إنشاء المشروع، ثم تسجيل مجلد وقراءة ملف وطلب معاينة تعديل من داخل نافذة Windows. أدلة الواجهة الآلية: اختبارات Flutter على أحدث مصدر بعد مزامنته إلى نسخة LocalAppData نجحت 22/22، ومنها ظهور إجراء المشروع على المقاسات الضيقة والواسعة، واستمرار تسجيل المجلدات، وإنشاء مجلد مشروع آمن، ومعاينة تعديل الملف قبل الموافقة؛ `flutter analyze --no-pub` بلا ملاحظات. بناء Windows Debug نظيف في مسار منفصل نجح خلال 370.4 ثانية لأن نسخة Debug السابقة كانت مقفلة بعملية مفتوحة؛ لم تُغلق أي نافذة. شُغّلت النسخة النظيفة مباشرة (PID 39440، العنوان `Mithqal AI`، HWND غير صفري و`Responding=True`) بالتزامن مع صحة API 8000. ما زالت تجربة الأزرار بصريًا وتدفق تسجيل/قراءة/معاينة من نافذة حية مطلوبة؛ أداة Windows GUI غير متاحة في جلسة Codex الحالية، لذا لم يُعلّم هذا البند مكتملًا.
- [ ] تجربة `flutter run` التفاعلية عبر السكريبت المحلي بعد إغلاق جلسات Debug القديمة؛ البناء وحده تحقق بالفعل.
7. [x] اختيار مساحة عمل وعرض الملفات المدعومة وتحديد ملفات للسؤال؛ التحقق من المجلدات والملفات على الخادم.
+15 -3
View File
@@ -48,6 +48,7 @@ MAX_PDF_ATTACHMENT_BYTES = 8 * 1024 * 1024
MAX_ATTACHMENT_TOTAL_BYTES = 16 * 1024 * 1024
MAX_OCR_CONTEXT_CHARS = 24_000
MAX_AGENT_TOOL_CALLS = 3
AGENT_STREAM_HEARTBEAT_SECONDS = 15.0
MAX_AGENT_KNOWLEDGE_CHUNKS = 4
READ_ONLY_AGENT_TOOLS = frozenset({"calculator", "search_workspace", "search_knowledge"})
@@ -806,7 +807,9 @@ async def index_workspace_knowledge(
raise HTTPException(status_code=413, detail="حد الفهرسة 2 ميغابايت لكل طلب.")
is_pdf = path.suffix.lower() == ".pdf"
if is_pdf:
parsed_pdf = extract_pdf_pages_text(raw, path.name)
parsed_pdf = await asyncio.to_thread(
extract_pdf_pages_text, raw, path.name
)
pdf_truncated = parsed_pdf["truncated"]
pdf_has_text_pages = any(page["has_text"] for page in parsed_pdf["pages"])
for page in parsed_pdf["pages"]:
@@ -833,14 +836,16 @@ async def index_workspace_knowledge(
try:
selected_scanned_pages = scanned_page_numbers[:MAX_SCANNED_PAGES]
ocr_truncated = len(scanned_page_numbers) > len(selected_scanned_pages)
rendered = render_scanned_pdf_pages(
rendered = await asyncio.to_thread(
render_scanned_pdf_pages,
raw,
path.name,
max_pages=MAX_SCANNED_PAGES,
page_numbers=selected_scanned_pages,
)
for page in rendered["pages"]:
recognized = recognize_image_text(
recognized = await asyncio.to_thread(
recognize_image_text,
base64.b64decode(page["data"]),
label=f"الصفحة {page['page']} من {path.name}",
)
@@ -2362,6 +2367,7 @@ async def run_agent_stream(
task = asyncio.create_task(
_execute_agent(request, report_progress=report, user_id=user_id)
)
last_output_at = perf_counter()
try:
while True:
if task.done() and queue.empty():
@@ -2369,8 +2375,14 @@ async def run_agent_stream(
try:
message = await asyncio.wait_for(queue.get(), timeout=0.25)
except TimeoutError:
if perf_counter() - last_output_at >= AGENT_STREAM_HEARTBEAT_SECONDS:
# Keep otherwise-idle SSE connections alive while the model
# or a local tool is taking several minutes to finish.
yield ": keep-alive\n\n"
last_output_at = perf_counter()
continue
yield f"event: progress\ndata: {json.dumps({'message': message}, ensure_ascii=False)}\n\n"
last_output_at = perf_counter()
try:
result = await task
except HTTPException as exc:
@@ -0,0 +1,37 @@
{
"created_at_utc": "2026-10-04T09:42:15.933573+00:00",
"base_url": "http://127.0.0.1:8000",
"dataset": "evals\\knowledge_retrieval_extended.jsonl",
"indexing": {
"semantic_indexed_documents": 1,
"documents": 1,
"embedding_models": [
"granite-embedding:278m"
]
},
"metrics": {
"cases": 1,
"hit_at_1": 1.0,
"hit_at_3": 1.0,
"mean_reciprocal_rank": 1.0,
"evidence_rate": 1.0
},
"results": [
{
"id": "pdf-archive-page-limit-en",
"query": "What is the maximum number of pages accepted when indexing one PDF?",
"expected_document": "archive_retention_policy.pdf",
"expected_evidence": "30 pages",
"rank": 1,
"top_paths": [
"archive_retention_policy.pdf"
],
"expected_document_chunks": [
0
],
"evidence_found": true,
"search_mode": "hybrid"
}
],
"note": "Small deterministic fixture set. Retrieval metrics cover source ranking and evidence presence, not general RAG quality. Optional agent answers are retained for human review and are not auto-scored."
}
@@ -0,0 +1,37 @@
{
"created_at_utc": "2026-10-04T09:45:22.724212+00:00",
"base_url": "http://127.0.0.1:8000",
"dataset": "evals\\knowledge_retrieval_extended.jsonl",
"indexing": {
"semantic_indexed_documents": 1,
"documents": 1,
"embedding_models": [
"granite-embedding:278m"
]
},
"metrics": {
"cases": 1,
"hit_at_1": 1.0,
"hit_at_3": 1.0,
"mean_reciprocal_rank": 1.0,
"evidence_rate": 1.0
},
"results": [
{
"id": "scanned-pdf-ocr-deadline-en",
"query": "What calendar date ends the archive restoration approval window?",
"expected_document": "scanned_approval_notice.pdf",
"expected_evidence": "17 October 2026",
"rank": 1,
"top_paths": [
"scanned_approval_notice.pdf"
],
"expected_document_chunks": [
0
],
"evidence_found": true,
"search_mode": "hybrid"
}
],
"note": "Small deterministic fixture set. Retrieval metrics cover source ranking and evidence presence, not general RAG quality. Optional agent answers are retained for human review and are not auto-scored."
}
@@ -0,0 +1,37 @@
{
"created_at_utc": "2026-10-04T09:19:36.348536+00:00",
"base_url": "http://127.0.0.1:8000",
"dataset": "evals\\knowledge_retrieval_extended.jsonl",
"indexing": {
"semantic_indexed_documents": 1,
"documents": 1,
"embedding_models": [
"granite-embedding:278m"
]
},
"metrics": {
"cases": 1,
"hit_at_1": 1.0,
"hit_at_3": 1.0,
"mean_reciprocal_rank": 1.0,
"evidence_rate": 1.0
},
"results": [
{
"id": "conversation-storage-ar",
"query": "أين تحفظ المحادثات؟",
"expected_document": "storage.md",
"expected_evidence": "SQLite",
"rank": 1,
"top_paths": [
"storage.md"
],
"expected_document_chunks": [
0
],
"evidence_found": true,
"search_mode": "hybrid"
}
],
"note": "Small deterministic fixture set. Retrieval metrics cover source ranking and evidence presence, not general RAG quality. Optional agent answers are retained for human review and are not auto-scored."
}
@@ -508,7 +508,7 @@ class ApiRepository {
headers: await _userHeaders(),
body: jsonEncode({'workspace_path': workspacePath, 'files': files}),
)
.timeout(const Duration(minutes: 2));
.timeout(const Duration(minutes: 5));
_checkStatus(response);
return jsonDecode(response.body) as Map<String, dynamic>;
}
@@ -337,25 +337,46 @@ class ChatCubit extends Cubit<ChatState> {
if (path == null || files.isEmpty) {
throw StateError('اختر مساحة عمل وحدد ملفًا واحدًا على الأقل للفهرسة.');
}
emit(state.copyWith(agentProgress: 'يفهرس الملفات المحددة محليًا…'));
emit(
state.copyWith(agentProgress: 'يجهز فهرسة ${files.length} ملفًا محليًا…'),
);
var indexedCount = 0;
var chunks = 0;
String? currentFile;
try {
final result = await _api.indexWorkspaceKnowledge(path, files);
final indexed = result['indexed'] as List<dynamic>? ?? const [];
final chunks = indexed.fold<int>(
0,
(total, item) =>
total + ((item as Map<String, dynamic>)['chunks'] as int? ?? 0),
);
for (var index = 0; index < files.length; index++) {
currentFile = files[index];
final fileName = currentFile.split(RegExp(r'[/\\]')).last;
emit(
state.copyWith(
agentProgress:
'يفهرس الملف ${index + 1}/${files.length}: $fileName…',
clearError: true,
),
);
final result = await _api.indexWorkspaceKnowledge(path, [currentFile]);
final indexed = result['indexed'] as List<dynamic>? ?? const [];
if (indexed.length != 1) {
throw StateError('لم يؤكد الخادم فهرسة الملف $fileName.');
}
indexedCount++;
chunks +=
(indexed.single as Map<String, dynamic>)['chunks'] as int? ?? 0;
}
if (!isClosed) {
emit(state.copyWith(clearAgentProgress: true, clearError: true));
}
return 'فُهرست ${indexed.length} ملفًا محليًا في SQLite ($chunks مقطعًا).';
return 'فُهرست $indexedCount ملفًا محليًا في SQLite ($chunks مقطعًا).';
} catch (error) {
final message = _readableError(error);
final detail =
indexedCount == 0
? 'تعذرت فهرسة ${currentFile ?? 'الملف المحدد'}: $message'
: 'فُهرست $indexedCount من ${files.length} ملفات. تعذرت فهرسة ${currentFile ?? 'الملف التالي'}: $message';
if (!isClosed) {
emit(state.copyWith(clearAgentProgress: true, error: message));
emit(state.copyWith(clearAgentProgress: true, error: detail));
}
throw Exception(message);
throw Exception(detail);
}
}
@@ -16,6 +16,8 @@ class _VersionTestApi extends ApiRepository {
SavedConversation? lastSaved;
String? indexedWorkspace;
List<String> indexedFiles = const [];
final indexedRequests = <List<String>>[];
final availableFiles = <String>['README.md'];
List<String> deletedFiles = const [];
String? registeredProject;
String? unregisteredProject;
@@ -60,9 +62,8 @@ class _VersionTestApi extends ApiRepository {
Future<List<SavedConversation>> listConversations() async => [];
@override
Future<List<String>> listWorkspaceFiles(String workspacePath) async => [
'README.md',
];
Future<List<String>> listWorkspaceFiles(String workspacePath) async =>
List.of(availableFiles);
@override
Future<List<String>> registerWorkspaceProject(String workspacePath) async {
@@ -82,6 +83,8 @@ class _VersionTestApi extends ApiRepository {
) async {
indexedWorkspace = workspacePath;
indexedFiles = files;
indexedRequests.add(List.of(files));
await Future<void>.delayed(const Duration(milliseconds: 1));
return {
'indexed': [
for (final file in files) {'path': file, 'chunks': 2},
@@ -175,21 +178,39 @@ void main() {
test('selected workspace files can be indexed as local knowledge', () async {
final api = _VersionTestApi();
api.availableFiles.add('lib/main.dart');
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
await Future<void>.delayed(Duration.zero);
await cubit.selectWorkspace(r'C:\Projects\sample');
cubit.selectWorkspaceFiles(['README.md']);
final progressUpdates = <String?>[];
final subscription = cubit.stream.listen(
(state) => progressUpdates.add(state.agentProgress),
);
cubit.selectWorkspaceFiles(['README.md', 'lib/main.dart']);
final message = await cubit.indexSelectedWorkspaceKnowledge();
expect(api.indexedWorkspace, r'C:\Projects\sample');
expect(api.indexedFiles, ['README.md']);
expect(message, contains('2 مقطعًا'));
expect(api.indexedRequests, [
['README.md'],
['lib/main.dart'],
]);
expect(message, contains('فُهرست 2 ملفًا'));
expect(message, contains('4 مقطعًا'));
expect(
progressUpdates,
contains('يفهرس الملف 1/2: README.md…'),
);
expect(
progressUpdates,
contains('يفهرس الملف 2/2: main.dart…'),
);
expect(cubit.state.agentProgress, isNull);
await subscription.cancel();
final removal = await cubit.removeSelectedWorkspaceKnowledge();
expect(api.deletedFiles, ['README.md']);
expect(removal, contains('أُزيل 1 ملف'));
expect(api.deletedFiles, ['README.md', 'lib/main.dart']);
expect(removal, contains('أُزيل 2 ملف'));
await cubit.close();
});
@@ -4,10 +4,12 @@ from __future__ import annotations
import argparse
import atexit
import hashlib
import json
import shutil
import sys
import tempfile
from contextlib import nullcontext
from datetime import datetime, timezone
from pathlib import Path
from urllib.error import HTTPError
@@ -68,6 +70,15 @@ def main() -> int:
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument("--dataset", type=Path, default=DATASET)
parser.add_argument("--output", type=Path, default=None)
parser.add_argument(
"--workspace-dir",
type=Path,
default=None,
help=(
"Use an existing empty directory beside or inside the project for fixtures. "
"Only fixture files whose contents remain unchanged are removed afterward."
),
)
parser.add_argument("--request-timeout", type=float, default=180.0)
parser.add_argument("--case", action="append", dest="case_ids")
parser.add_argument(
@@ -90,6 +101,23 @@ def main() -> int:
if not cases or len({case["id"] for case in cases}) != len(cases):
raise ValueError("Retrieval dataset must be non-empty with unique IDs.")
if args.workspace_dir is None:
workspace_context = tempfile.TemporaryDirectory(
prefix="sovereignai-rag-eval-"
)
else:
workspace_path = args.workspace_dir.resolve(strict=True)
if not (
workspace_path.is_relative_to(ROOT)
or workspace_path.parent == ROOT.parent
):
parser.error(
"--workspace-dir must be inside the project or its immediate parent."
)
if not workspace_path.is_dir() or any(workspace_path.iterdir()):
parser.error("--workspace-dir must be an existing empty directory.")
workspace_context = nullcontext(str(workspace_path))
results: list[dict] = []
index_summary: dict[str, object] = {}
files = sorted({case["document"] for case in cases})
@@ -107,10 +135,18 @@ def main() -> int:
pass
atexit.register(logout)
with tempfile.TemporaryDirectory(prefix="sovereignai-rag-eval-") as temporary:
with workspace_context as temporary:
workspace_path = Path(temporary)
fixture_digests: dict[Path, str] = {}
for case in cases:
destination = workspace_path / case["document"]
relative_document = Path(case["document"])
if (
relative_document.is_absolute()
or ".." in relative_document.parts
or not relative_document.parts
):
parser.error(f"Unsafe fixture document path: {case['document']!r}")
destination = workspace_path / relative_document
destination.parent.mkdir(parents=True, exist_ok=True)
source_path = case.get("source_path")
if source_path:
@@ -120,6 +156,9 @@ def main() -> int:
shutil.copyfile(source, destination)
elif not destination.exists():
destination.write_text(case["text"], encoding="utf-8")
fixture_digests[relative_document] = hashlib.sha256(
destination.read_bytes()
).hexdigest()
index_payload = {"workspace_path": str(workspace_path), "files": files}
index_attempted = False
project_registration_attempted = False
@@ -138,12 +177,24 @@ def main() -> int:
if Path(registration.get("path", "")).resolve() != workspace_path.resolve():
raise RuntimeError("The API registered a different fixture workspace.")
index_attempted = True
index_result = post_json(
index_url, index_payload, timeout=args.request_timeout, token=token
)
if len(index_result.get("indexed", [])) != len(files):
raise RuntimeError("The API did not confirm every fixture document.")
indexed_documents = index_result["indexed"]
indexed_documents = []
for file_number, relative_path in enumerate(files, start=1):
print(
f"Indexing fixture {file_number}/{len(files)}: {relative_path}",
flush=True,
)
index_result = post_json(
index_url,
{"workspace_path": str(workspace_path), "files": [relative_path]},
timeout=args.request_timeout,
token=token,
)
indexed = index_result.get("indexed", [])
if len(indexed) != 1:
raise RuntimeError(
f"The API did not confirm fixture document {relative_path}."
)
indexed_documents.extend(indexed)
index_summary = {
"semantic_indexed_documents": sum(
bool(item.get("semantic_indexed")) for item in indexed_documents
@@ -240,6 +291,42 @@ def main() -> int:
)
except Exception as cleanup_error:
cleanup_errors.append(cleanup_error)
if args.workspace_dir is not None:
for relative_document, expected_digest in fixture_digests.items():
destination = workspace_path / relative_document
try:
if not destination.is_file():
raise RuntimeError(
f"Evaluation fixture disappeared: {relative_document}"
)
actual_digest = hashlib.sha256(destination.read_bytes()).hexdigest()
if actual_digest != expected_digest:
raise RuntimeError(
f"Evaluation fixture changed; leaving it in place: {relative_document}"
)
destination.unlink()
except Exception as cleanup_error:
cleanup_errors.append(cleanup_error)
parents = sorted(
{
(workspace_path / relative_document).parent
for relative_document in fixture_digests
},
key=lambda path: len(path.parts),
reverse=True,
)
for parent in parents:
if parent == workspace_path:
continue
try:
parent.rmdir()
except OSError:
if parent.exists():
cleanup_errors.append(
RuntimeError(
f"Evaluation directory is not empty; leaving it in place: {parent}"
)
)
if cleanup_errors:
if original_error:
for cleanup_error in cleanup_errors:
@@ -1,5 +1,6 @@
import os
import tempfile
import asyncio
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
@@ -50,7 +51,18 @@ class MixedPdfKnowledgeIntegrationTests(unittest.TestCase):
database.initialize_database()
knowledge.initialize()
client = authenticated_client(app)
with patch("app.main.recognize_image_text", return_value=ocr):
ocr_execution_contexts: list[str] = []
def fake_ocr(*_args, **_kwargs):
try:
asyncio.get_running_loop()
except RuntimeError:
ocr_execution_contexts.append("worker")
else:
ocr_execution_contexts.append("event-loop")
return ocr
with patch("app.main.recognize_image_text", side_effect=fake_ocr):
indexed = client.post(
"/v1/agent/knowledge/index",
json={"workspace_path": str(workspace), "files": ["mixed.pdf"]},
@@ -75,6 +87,7 @@ class MixedPdfKnowledgeIntegrationTests(unittest.TestCase):
self.assertEqual(indexed.status_code, 200, indexed.text)
self.assertEqual(indexed.json()["indexed"][0]["ocr_pages"], 1)
self.assertEqual(ocr_execution_contexts, ["worker"])
self.assertTrue(indexed.json()["indexed"][0]["semantic_indexed"])
self.assertEqual(digital.status_code, 200, digital.text)
self.assertIn("IndexMarker", digital.json()["results"][0]["text"])
@@ -8,8 +8,10 @@ import httpx
from fastapi import HTTPException
# Importing the API initializes SQLite; keep this test process away from user data.
_TEST_DATA_DIR = tempfile.TemporaryDirectory(prefix="sovereignai-timeout-tests-")
os.environ["SOVEREIGNAI_DATA_DIR"] = _TEST_DATA_DIR.name
_TEST_DATA_DIR = None
if not os.environ.get("SOVEREIGNAI_DATA_DIR"):
_TEST_DATA_DIR = tempfile.TemporaryDirectory(prefix="sovereignai-timeout-tests-")
os.environ["SOVEREIGNAI_DATA_DIR"] = _TEST_DATA_DIR.name
from app import database
from app.main import AgentRequest, run_agent_stream
@@ -93,6 +95,32 @@ class TimeoutAndCancellationTests(unittest.IsolatedAsyncioTestCase):
await stream.aclose()
await asyncio.wait_for(cancelled.wait(), timeout=1)
async def test_idle_agent_stream_emits_keepalive_until_model_finishes(self) -> None:
started = asyncio.Event()
cancelled = asyncio.Event()
async def waiting_agent(_request, *, report_progress, user_id):
self.assertEqual(user_id, database.LOCAL_USER_ID)
started.set()
try:
await asyncio.Future()
except asyncio.CancelledError:
cancelled.set()
raise
request = AgentRequest(task="اختبار نبضة اتصال الوكيل")
with (
patch("app.main._execute_agent", side_effect=waiting_agent),
patch("app.main.AGENT_STREAM_HEARTBEAT_SECONDS", 0.01),
):
response = await run_agent_stream(request, user_id=database.LOCAL_USER_ID)
stream = response.body_iterator
keepalive = await asyncio.wait_for(anext(stream), timeout=1)
self.assertEqual(keepalive, ": keep-alive\n\n")
self.assertTrue(started.is_set())
await stream.aclose()
await asyncio.wait_for(cancelled.wait(), timeout=1)
if __name__ == "__main__":
unittest.main()