Record retrieval and Gemma evaluation results
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
|
||||
- تحقق تشغيل حديث: أُعيد تشغيل FastAPI على `127.0.0.1:8000` من نسخة المشروع الحالية مع Ollama وGemma 4؛ `/health` أعاد `ok`، والجلسة المحلية أكدت وضع الوكيل متعدد الخطوات وحده 3، وتجربة API حية اختارت `calculator` وأعادت `391.0` لـ17×23. في 2026-10-03 اختُبر أيضًا طلب بحث+حساب حي: `search_workspace` أعاد `MAX_AGENT_TOOL_CALLS = 3` من `app/main.py`، ثم الحاسبة أعادت `21` لـ3×7 في خطوتين. بقي تطبيق Windows Debug شغّالًا، وظل ملف SQLite المحلي موجودًا في مساره. اختبارات Python الكاملة 83/83؛ اختبارات Flutter 15/15 و`flutter analyze` بلا ملاحظات من الجولة السابقة، ولم تتغير واجهة Flutter في هذه الخطوة.
|
||||
|
||||
- 2026-10-03: أعيد تقييم البحث الهجين على 9 أسئلة بملفات المشروع الفعلية. فُهرست 4 ملفات بنموذج `granite-embedding:278m`، وأعاد البحث الملف المقصود ضمن أول 3 نتائج في 9/9، وفي المركز الأول في 6/9، ووجد النص الداعم المتوقع في 8/9؛ `MRR=0.833`. صُحح توقع اختبار PDF من قيمة افتراضية قديمة إلى الثابت الحقيقي `MAX_PDF_PAGES = 30`. بقي سؤال صلاحيات الأداة دون المقطع الدقيق في `main.py`. التقرير `evals/results/retrieval_2026-10-03_145357.json`. التقييم صغير ويقيس الاسترجاع لا جودة إجابة Gemma؛ لم تحصل مراجعة بشرية بعد. أداة التقييم صارت تستخدم جلسة loopback مؤقتة وتلغيها، وتسجل نمط البحث والتضمين، مع مهلة أطول للفهرسة على CPU.
|
||||
|
||||
- الأساس المحلي يعمل: Flutter Windows Debug ↔ FastAPI ↔ Ollama، مع سجل SQLite للمحادثات ونسخ الإجابات. Gemma 4 E2B هو الافتراضي للنصوص، وطلبات الصور وPDFات الممسوحة تتحول تلقائيًا إلى Ministral 3:3b المحلي عند توفره. أضيف عقد أدوات الوكيل وسجل تدقيق للبيانات الوصفية فقط؛ ينفذ الوكيل حتى ثلاث خطوات أدوات مسموحة بالتتابع ثم يعيد النتائج إلى Gemma لصياغة الرد. عند طلب المستخدم البحث صراحةً في المشروع، يجلب الخادم مقتطفات المساحة المخصصة أولًا ويمررها كبيانات غير موثوقة. توجد ثلاث مهارات محلية قابلة للاختيار مع قائمة أدوات مسموحة يتحقق منها الخادم في كل خطوة. فهرس SQLite يدعم FTS5 وتضمينات Granite محلية اختيارية لملفات النص والكود وPDF الرقمي والممسوح والمختلط؛ PDF الممسوح يفهرس أول 3 صفحات عبر OCR الاختياري. الوكيل يسترجع المقاطع نصيًا ودلاليًا ويهمل النتائج القديمة عند تغير الملف. تجربة تضمين API حقيقية أعادت الملف المقصود أولًا في 3/3 أسئلة عربية؛ بقي توسيع التقييم على ملفات واقعية ومعايرة OCR وترتيب الأعمدة.
|
||||
- اكتملت وظائف المحادثة الأساسية وطبقة مزوّد Ollama، بما فيها جداول Markdown القابلة للتمرير والقوائم المتداخلة ومربعات مهام GFM؛ اجتاز التطبيق تحليل Flutter و10 اختبارات واجهة وحالة.
|
||||
- الوكيل يقرأ ملفات يختارها المستخدم، ويمكنه اقتراح إنشاء/تحديث ملف داخل مساحة العمل فقط. يعرض التطبيق diff ويطلب الموافقة قبل الكتابة؛ لا يخزّن الخادم الملفات ولا يشغل كودها. يبث الخادم مراحل التحقق واختيار الأداة والتنفيذ وصياغة الرد إلى واجهة Flutter.
|
||||
@@ -131,7 +133,7 @@
|
||||
- [x] حذف مستندات محددة من فهرس SQLite عبر API وزر الواجهة. قبل إعادة أي مقطع يتحقق البحث من بصمة الملف؛ إذا تغيّر أو اختفى يحذف فهرسه القديم، ثم يعاد فهرسته باختيار المستخدم؛ تغطي اختبارات Python كشف التغيير.
|
||||
- [x] فحص استرجاع أولي من 11 سؤالًا عربيًا وإنجليزيًا على API حي. ظهرت مشكلة مرادفات عربية في سؤال الصوت وترتيب ضعيف لسؤال تحويل الصور؛ أضفنا توسيعًا محدودًا لمرادفات التفريغ الصوتي وأعدنا التقييم: Hit@1=1.0 وHit@3=1.0 وMRR=1.0 ومعدل ظهور الدليل=1.0. قبل التحسين كان Hit@1=0.818. حُذفت ملفات التقييم المؤقتة من الفهرس بعد القياس. النتائج: `retrieval_2026-10-02_140339.json` و`retrieval_2026-10-02_140538.json`. تبقى العينة مصطنعة وصغيرة، فلا تثبت جودة عامة.
|
||||
- [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` (بعده). العينة صغيرة واختبارها معجمي؛ الدقة الدلالية وجودة الإجابة لم تُقاسا.
|
||||
- توسيع التقييم إلى مستندات واقعية أطول وPDFات ومسائل بصياغات بعيدة عن مفردات المصدر، ثم مراجعة بشرية لجودة إجابات النموذج فوق السياق المسترجع.
|
||||
- [ ] توسيع تقييم الاسترجاع إلى مستندات أطول وPDFات وأسئلة بعيدة عن ألفاظ المصدر، وتحسين استرجاع المقاطع العميقة (في القياس الحالي لم يظهر مقطع فحص صلاحيات الأداة)، ثم مراجعة بشرية لجودة إجابات النموذج فوق السياق المسترجع. قياس 2026-10-03: 9 حالات على 4 ملفات كود، `Hit@1=0.667`, `Hit@3=1.0`, `MRR=0.833`, وظهور الدليل `8/9` باستخدام البحث الهجين المحلي. لا تمثل هذه العينة الصغيرة اكتمال البند.
|
||||
- [x] مهارات محلية أولية قابلة للاختيار من واجهة وضع الوكيل: شرح الكود، مراجعة الكود، وخطة اختبارات. تعرض `/v1/agent/skills` وصف كل مهارة وأدواتها؛ تُحقن التعليمات الموثوقة في سياق الوكيل وتُفلتر قائمة الأدوات، ويرفض الخادم استدعاء أداة خارج صلاحيات المهارة. لمهارة مراجعة الكود معاينة فقط ولا تطبيق مباشر. اجتازت اختبارات الصلاحيات والواجهة؛ يبقى تقييم دقة كل مهارة على أمثلة أكثر قبل اعتمادها افتراضيًا.
|
||||
- [x] قراءة رابط عام محدد عبر `POST /v1/web/read`: استخراج نص HTML الثابت وتمريره إلى Gemma المحلية للإجابة مع إرجاع الرابط والعنوان.
|
||||
- [x] بحث ويب متعدد المصادر تجريبي عبر DuckDuckGo بلا مفتاح API: اختيار نطاقات مختلفة، محاولة جلب الصفحات العامة، تلخيص بالنموذج المحلي مع روابط المصادر، ووسم المقتطفات عند تعذر فتح الصفحة. (2026-10-01: استجابة حية أعادت ملخصًا ومصدرين مختلفين عبر FastAPI/Gemma.) أضيف زمن جلب إجمالي وزمن لكل مصدر إلى الاستجابة لمساعدة تشخيص المصادر البطيئة؛ اختبارات الحجب ومزوّد رسمي اختياري ما زالت لاحقًا.
|
||||
@@ -147,7 +149,7 @@
|
||||
## المرحلة 7 — تقييم النماذج والتدريب
|
||||
|
||||
- [x] إنشاء مجموعة أولية من خمسة أسئلة عربية/برمجية مع معايير مراجعة بشرية وفحوص شكل/قيمة محدودة، ومشغّل يسجل الأجوبة ووقت كل طلب واسم أداة الوكيل في JSON. تجربة واحدة على Gemma 4 E2B وQwen 2.5 1.5B وحُفظت النتائج في `evals/results/` (2026-10-02).
|
||||
- تكرار التقييم مع إجابات مرجعية ومراجعين/درجات بشرية، وقياس استهلاك الذاكرة وتوحيد حالة الإحماء قبل اعتماد مقارنة بين النماذج. أضيف الإحماء وجرى تشغيل مرة إضافية لكل نموذج على خمسة أسئلة؛ هذه العينة اتجاهية ولم تُكرر عدة مرات ولم تُراجع من المستخدم. الجولة السابقة كانت Gemma 8/10 بمتوسط 16.32 ثانية؛ Qwen 5/10 بمتوسط 8.93 ثانية. أظهرت الجولة الدافئة تفاوتًا في اتباع التعليمات، كما أظهرت أن الحساب المباشر قد يتجاوز الأداة وأن Qwen قد يرسل صيغة رياضية غير معيارية؛ عولج المساران واختُبر كل نموذج على طلب الحاسبة، لكن يلزم إعادة تقييم المجموعة كاملة بعد الإصلاح مع مراجعة بشرية وقياس الذاكرة.
|
||||
- تكرار التقييم مع إجابات مرجعية ومراجعين/درجات بشرية، وقياس استهلاك الذاكرة واعتماد مقارنة قابلة للتكرار. (2026-10-03: أُعيد تشغيل المجموعة بعد إصلاحات الوكيل على Gemma 4 E2B مع warmup 24.5 ثانية؛ 5/5 طلبات بلا أخطاء، الفحوص التلقائية 4/4، ومتوسط زمن الرد 14.61 ثانية، الوسيط 12.91، والمدى 0.03–25.93 ثانية. تقرير `evals/results/gemma4_e2b_2026-10-03_175827.json`. الإجابات محفوظة للمراجعة، لكن التقييم البشري فارغ. قراءة عملية Ollama وحدها أعطت 120.7MB private/87MB working set بعد التشغيل؛ هذا لا يشمل بالضرورة عامل النموذج أو ذاكرة الأوزان، فلا يُعد قياسًا صالحًا لاستهلاك النموذج. تبقى درجات بشرية، قياس RAM/VRAM peak موثوق، وإعادة متعددة لتوحيد المقارنة.) الجولة الدافئة السابقة كانت Gemma 8/10 بمتوسط 16.32 ثانية وQwen 5/10 بمتوسط 8.93 ثانية. أظهرت آنذاك مشكلة الحساب المباشر مقابل الأداة وصيغة Qwen الرياضية؛ أُصلح المساران، وأداة التقييم الآن تستخدم جلسة loopback مؤقتة.
|
||||
- اختيار النماذج حسب الرخصة، الجودة، اللغة، دعم الأدوات، كمية VRAM، وإمكانية التشغيل التجاري. لا نعتمد وصف «مفتوح» وحده كإثبات سماح تجاري؛ نراجع الرخصة الرسمية لكل إصدار.
|
||||
- تحسين أولي عبر prompts وRAG والأدوات؛ هذه غالبًا تعالج نقص المعرفة أو القدرة على الفعل دون تغيير أوزان النموذج.
|
||||
- عند توفر GPU مناسب: تجربة LoRA/QLoRA على بيانات مرخصة ومنقحة، ومقارنة النتائج بالمجموعة المرجعية قبل اعتماد adapter.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{"id":"project-index-stale-source","query":"Which file hash is compared with the stored content hash before returning a search result?","document":"fixture_knowledge.py","source_path":"app/knowledge.py","expected_evidence":"actual_hash == row[\"content_hash\"]"}
|
||||
{"id":"project-chunk-overlap","query":"What are the configured chunk size and overlap for local knowledge indexing?","document":"fixture_knowledge.py","source_path":"app/knowledge.py","expected_evidence":"CHUNK_OVERLAP = 120"}
|
||||
{"id":"project-arabic-audio-variants","query":"كيف يربط فهرس المعرفة بين تعابير عربية مختلفة عند البحث عن تفريغ التسجيل؟","document":"fixture_knowledge.py","source_path":"app/knowledge.py","expected_evidence":"QUERY_SYNONYMS"}
|
||||
{"id":"project-pdf-page-bound","query":"What is the maximum number of PDF pages accepted during local extraction?","document":"fixture_pdf.py","source_path":"app/pdf_documents.py","expected_evidence":"max_pages: int = 30"}
|
||||
{"id":"project-pdf-page-bound","query":"What is the maximum number of PDF pages accepted during local extraction?","document":"fixture_pdf.py","source_path":"app/pdf_documents.py","expected_evidence":"MAX_PDF_PAGES = 30"}
|
||||
{"id":"project-pdf-empty-scan","query":"ما الذي يحدث إذا رفع المستخدم ملف PDF ممسوحًا ولا يحتوي نصًا قابلًا للاستخراج؟","document":"fixture_pdf.py","source_path":"app/pdf_documents.py","expected_evidence":"لا يحتوي PDF على نص قابل للاستخراج"}
|
||||
{"id":"project-skill-tool-filter","query":"How is a tool call blocked when it is outside the selected skill permissions?","document":"fixture_main.py","source_path":"app/main.py","expected_evidence":"tool_name not in selected_skill.allowed_tools"}
|
||||
{"id":"project-agent-index-limits","query":"What file count and total size limits apply when indexing local knowledge?","document":"fixture_knowledge.py","source_path":"app/knowledge.py","expected_evidence":"MAX_TOTAL_CHARS = 2_000_000"}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"created_at_utc": "2026-10-03T14:58:27.021486+00:00",
|
||||
"base_url": "http://127.0.0.1:8000",
|
||||
"requested_model": "gemma4:e2b",
|
||||
"dataset": "evals\\baseline_arabic_programming.jsonl",
|
||||
"evaluation_note": "Automatic checks are narrow format/value checks. Human rating uses 0-2 per case (0 failed, 1 partial, 2 meets rubric); one pass is directional only, not a statistical comparison.",
|
||||
"warmup": {
|
||||
"completed": true,
|
||||
"elapsed_seconds": 24.5
|
||||
},
|
||||
"latency_summary_seconds": {
|
||||
"mean": 14.61,
|
||||
"median": 12.91,
|
||||
"min": 0.03,
|
||||
"max": 25.93
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"id": "ar-greeting",
|
||||
"category": "arabic",
|
||||
"rubric": [
|
||||
"إجابة عربية واضحة ومترابطة",
|
||||
"جملة واحدة، وخطوة عملية مناسبة للمبتدئ"
|
||||
],
|
||||
"status": "ok",
|
||||
"elapsed_seconds": 12.91,
|
||||
"answer": "يمكنك البدء بتثبيت وتشغيل نماذج مفتوحة المصدر أو استخدام برامج متخصصة لتشغيل المساعدات المحلية.",
|
||||
"tool": null,
|
||||
"checks": [],
|
||||
"human_rating": null,
|
||||
"human_notes": ""
|
||||
},
|
||||
{
|
||||
"id": "identity-honesty",
|
||||
"category": "honesty",
|
||||
"rubric": [
|
||||
"يصرح بأنه لا يملك وصولًا إلى GPS أو الموقع الحالي",
|
||||
"لا يخمّن مدينة أو عنوانًا"
|
||||
],
|
||||
"status": "ok",
|
||||
"elapsed_seconds": 10.05,
|
||||
"answer": "أنا لا أستطيع معرفة موقعك الحالي عبر GPS.",
|
||||
"tool": null,
|
||||
"checks": [],
|
||||
"human_rating": null,
|
||||
"human_notes": ""
|
||||
},
|
||||
{
|
||||
"id": "python-function",
|
||||
"category": "programming",
|
||||
"rubric": [
|
||||
"الكود صحيح ويغطي الزوجي والفردي",
|
||||
"يوجد مثال تشغيل ونتيجته"
|
||||
],
|
||||
"status": "ok",
|
||||
"elapsed_seconds": 24.13,
|
||||
"answer": "```python\ndef is_even(number):\n \"\"\"تتحقق مما إذا كان العدد المُدخل زوجيًا.\"\"\"\n if number % 2 == 0:\n return True\n else:\n return False\n\n# مثال استخدام قصير\nnumber = 4\nif is_even(number):\n print(f\"{number} عدد زوجي.\")\nelse:\n print(f\"{number} عدد فردي.\")\n```",
|
||||
"tool": null,
|
||||
"checks": [
|
||||
{
|
||||
"check": {
|
||||
"type": "fenced_code",
|
||||
"language": "python"
|
||||
},
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"check": {
|
||||
"type": "contains",
|
||||
"value": "def is_even"
|
||||
},
|
||||
"passed": true
|
||||
}
|
||||
],
|
||||
"human_rating": null,
|
||||
"human_notes": ""
|
||||
},
|
||||
{
|
||||
"id": "sqlite-explainer",
|
||||
"category": "instruction-following",
|
||||
"rubric": [
|
||||
"ثلاث نقاط بالضبط",
|
||||
"شرح صحيح ومختصر"
|
||||
],
|
||||
"status": "ok",
|
||||
"elapsed_seconds": 25.93,
|
||||
"answer": "إليك شرح SQLite في ثلاث نقاط:\n\n1. **قاعدة بيانات في ملف واحد:** هي نظام قواعد بيانات علائقي خفيف الوزن يتم تخزين بياناته بالكامل في ملف واحد.\n2. **بدون خادم منفصل:** لا تتطلب خادماً (server) منفصلاً، مما يجعلها مثالية للتطبيقات الصغيرة والأجهزة المحمولة.\n3. **نظام SQL:** تستخدم لغة الاستعلامات المهيكلة (SQL) لإدارة البيانات واسترجاعها وتعديلها.",
|
||||
"tool": null,
|
||||
"checks": [],
|
||||
"human_rating": null,
|
||||
"human_notes": ""
|
||||
},
|
||||
{
|
||||
"id": "arithmetic-tool",
|
||||
"category": "tool-use",
|
||||
"rubric": [
|
||||
"استُدعيت أداة الحاسبة عند توفرها",
|
||||
"الناتج 3973 دون ادعاء غير صحيح"
|
||||
],
|
||||
"status": "ok",
|
||||
"elapsed_seconds": 0.03,
|
||||
"answer": "3973.0",
|
||||
"tool": "calculator",
|
||||
"checks": [
|
||||
{
|
||||
"check": {
|
||||
"type": "contains",
|
||||
"value": "3973"
|
||||
},
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"check": {
|
||||
"type": "tool_is",
|
||||
"value": "calculator"
|
||||
},
|
||||
"passed": true
|
||||
}
|
||||
],
|
||||
"human_rating": null,
|
||||
"human_notes": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"created_at_utc": "2026-10-03T14:53:57.594426+00:00",
|
||||
"base_url": "http://127.0.0.1:8000",
|
||||
"dataset": "evals\\knowledge_retrieval_project.jsonl",
|
||||
"indexing": {
|
||||
"semantic_indexed_documents": 4,
|
||||
"documents": 4,
|
||||
"embedding_models": [
|
||||
"granite-embedding:278m"
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"cases": 9,
|
||||
"hit_at_1": 0.6666666666666666,
|
||||
"hit_at_3": 1.0,
|
||||
"mean_reciprocal_rank": 0.8333333333333334,
|
||||
"evidence_rate": 0.8888888888888888
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"id": "project-path-confinement",
|
||||
"query": "Does resolving a requested source path guarantee it stays under the selected root?",
|
||||
"expected_document": "fixture_workspace.py",
|
||||
"expected_evidence": "relative_to(root)",
|
||||
"rank": 1,
|
||||
"top_paths": [
|
||||
"fixture_workspace.py",
|
||||
"fixture_main.py"
|
||||
],
|
||||
"evidence_found": true,
|
||||
"search_mode": "hybrid"
|
||||
},
|
||||
{
|
||||
"id": "project-preview-stale-file",
|
||||
"query": "How does applying an approved preview detect that the original file changed after review?",
|
||||
"expected_document": "fixture_workspace.py",
|
||||
"expected_evidence": "expected_hash",
|
||||
"rank": 2,
|
||||
"top_paths": [
|
||||
"fixture_main.py",
|
||||
"fixture_workspace.py"
|
||||
],
|
||||
"evidence_found": true,
|
||||
"search_mode": "hybrid"
|
||||
},
|
||||
{
|
||||
"id": "project-index-stale-source",
|
||||
"query": "Which file hash is compared with the stored content hash before returning a search result?",
|
||||
"expected_document": "fixture_knowledge.py",
|
||||
"expected_evidence": "actual_hash == row[\"content_hash\"]",
|
||||
"rank": 1,
|
||||
"top_paths": [
|
||||
"fixture_knowledge.py",
|
||||
"fixture_main.py",
|
||||
"fixture_workspace.py"
|
||||
],
|
||||
"evidence_found": true,
|
||||
"search_mode": "hybrid"
|
||||
},
|
||||
{
|
||||
"id": "project-chunk-overlap",
|
||||
"query": "What are the configured chunk size and overlap for local knowledge indexing?",
|
||||
"expected_document": "fixture_knowledge.py",
|
||||
"expected_evidence": "CHUNK_OVERLAP = 120",
|
||||
"rank": 1,
|
||||
"top_paths": [
|
||||
"fixture_knowledge.py",
|
||||
"fixture_main.py"
|
||||
],
|
||||
"evidence_found": true,
|
||||
"search_mode": "hybrid"
|
||||
},
|
||||
{
|
||||
"id": "project-arabic-audio-variants",
|
||||
"query": "كيف يربط فهرس المعرفة بين تعابير عربية مختلفة عند البحث عن تفريغ التسجيل؟",
|
||||
"expected_document": "fixture_knowledge.py",
|
||||
"expected_evidence": "QUERY_SYNONYMS",
|
||||
"rank": 1,
|
||||
"top_paths": [
|
||||
"fixture_knowledge.py",
|
||||
"fixture_main.py"
|
||||
],
|
||||
"evidence_found": true,
|
||||
"search_mode": "hybrid"
|
||||
},
|
||||
{
|
||||
"id": "project-pdf-page-bound",
|
||||
"query": "What is the maximum number of PDF pages accepted during local extraction?",
|
||||
"expected_document": "fixture_pdf.py",
|
||||
"expected_evidence": "MAX_PDF_PAGES = 30",
|
||||
"rank": 1,
|
||||
"top_paths": [
|
||||
"fixture_pdf.py",
|
||||
"fixture_main.py"
|
||||
],
|
||||
"evidence_found": true,
|
||||
"search_mode": "hybrid"
|
||||
},
|
||||
{
|
||||
"id": "project-pdf-empty-scan",
|
||||
"query": "ما الذي يحدث إذا رفع المستخدم ملف PDF ممسوحًا ولا يحتوي نصًا قابلًا للاستخراج؟",
|
||||
"expected_document": "fixture_pdf.py",
|
||||
"expected_evidence": "لا يحتوي PDF على نص قابل للاستخراج",
|
||||
"rank": 2,
|
||||
"top_paths": [
|
||||
"fixture_main.py",
|
||||
"fixture_pdf.py"
|
||||
],
|
||||
"evidence_found": true,
|
||||
"search_mode": "hybrid"
|
||||
},
|
||||
{
|
||||
"id": "project-skill-tool-filter",
|
||||
"query": "How is a tool call blocked when it is outside the selected skill permissions?",
|
||||
"expected_document": "fixture_main.py",
|
||||
"expected_evidence": "tool_name not in selected_skill.allowed_tools",
|
||||
"rank": 2,
|
||||
"top_paths": [
|
||||
"fixture_workspace.py",
|
||||
"fixture_main.py"
|
||||
],
|
||||
"evidence_found": false,
|
||||
"search_mode": "hybrid"
|
||||
},
|
||||
{
|
||||
"id": "project-agent-index-limits",
|
||||
"query": "What file count and total size limits apply when indexing local knowledge?",
|
||||
"expected_document": "fixture_knowledge.py",
|
||||
"expected_evidence": "MAX_TOTAL_CHARS = 2_000_000",
|
||||
"rank": 1,
|
||||
"top_paths": [
|
||||
"fixture_knowledge.py",
|
||||
"fixture_main.py",
|
||||
"fixture_workspace.py"
|
||||
],
|
||||
"evidence_found": true,
|
||||
"search_mode": "hybrid"
|
||||
}
|
||||
],
|
||||
"note": "Small deterministic fixture set; measures configured retrieval (keyword or hybrid) and evidence presence, not answer quality or general RAG quality."
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -15,19 +16,37 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
DATASET = ROOT / "evals" / "knowledge_retrieval.jsonl"
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict, timeout: float = 15.0) -> dict:
|
||||
def post_json(
|
||||
url: str,
|
||||
payload: dict,
|
||||
timeout: float = 15.0,
|
||||
*,
|
||||
token: str | None = None,
|
||||
) -> dict:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
request = Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = Request(url, data=body, headers=headers, method="POST")
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def delete_json(url: str, payload: dict, timeout: float = 15.0) -> dict:
|
||||
def delete_json(
|
||||
url: str,
|
||||
payload: dict,
|
||||
timeout: float = 15.0,
|
||||
*,
|
||||
token: str | None = None,
|
||||
) -> dict:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers=headers,
|
||||
method="DELETE",
|
||||
)
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
@@ -39,6 +58,7 @@ 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("--request-timeout", type=float, default=180.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
cases = [
|
||||
@@ -50,9 +70,21 @@ def main() -> int:
|
||||
raise ValueError("Retrieval dataset must be non-empty with unique IDs.")
|
||||
|
||||
results: list[dict] = []
|
||||
index_summary: dict[str, object] = {}
|
||||
files = sorted({case["document"] for case in cases})
|
||||
index_url = args.base_url.rstrip("/") + "/v1/agent/knowledge/index"
|
||||
search_url = args.base_url.rstrip("/") + "/v1/agent/knowledge/search"
|
||||
base_url = args.base_url.rstrip("/")
|
||||
session = post_json(base_url + "/v1/auth/local-session", {})
|
||||
token = session["access_token"]
|
||||
|
||||
def logout() -> None:
|
||||
try:
|
||||
post_json(base_url + "/v1/auth/logout", {}, token=token)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
atexit.register(logout)
|
||||
with tempfile.TemporaryDirectory(prefix="sovereignai-rag-eval-") as temporary:
|
||||
workspace_path = Path(temporary)
|
||||
for case in cases:
|
||||
@@ -68,13 +100,31 @@ def main() -> int:
|
||||
destination.write_text(case["text"], encoding="utf-8")
|
||||
index_payload = {"workspace_path": str(workspace_path), "files": files}
|
||||
try:
|
||||
index_result = post_json(index_url, index_payload)
|
||||
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"]
|
||||
index_summary = {
|
||||
"semantic_indexed_documents": sum(
|
||||
bool(item.get("semantic_indexed")) for item in indexed_documents
|
||||
),
|
||||
"documents": len(indexed_documents),
|
||||
"embedding_models": sorted(
|
||||
{
|
||||
item["embedding_model"]
|
||||
for item in indexed_documents
|
||||
if item.get("embedding_model")
|
||||
}
|
||||
),
|
||||
}
|
||||
for case in cases:
|
||||
response = post_json(
|
||||
search_url,
|
||||
{"workspace_path": str(workspace_path), "task": case["query"]},
|
||||
timeout=args.request_timeout,
|
||||
token=token,
|
||||
)
|
||||
retrieved = response.get("results", [])
|
||||
ranked_paths = list(dict.fromkeys(item["path"] for item in retrieved))
|
||||
@@ -93,13 +143,21 @@ def main() -> int:
|
||||
"rank": rank,
|
||||
"top_paths": ranked_paths,
|
||||
"evidence_found": evidence_found,
|
||||
"search_mode": response.get("search_mode", "unknown"),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
deletion = delete_json(index_url, index_payload)
|
||||
deletion = delete_json(
|
||||
index_url,
|
||||
index_payload,
|
||||
timeout=args.request_timeout,
|
||||
token=token,
|
||||
)
|
||||
if len(deletion.get("deleted", [])) != len(files):
|
||||
raise RuntimeError("The API did not confirm cleanup for every fixture document.")
|
||||
|
||||
logout()
|
||||
|
||||
total = len(results)
|
||||
metrics = {
|
||||
"cases": total,
|
||||
@@ -114,9 +172,10 @@ def main() -> int:
|
||||
"created_at_utc": timestamp.isoformat(),
|
||||
"base_url": args.base_url,
|
||||
"dataset": str(args.dataset.relative_to(ROOT) if args.dataset.is_relative_to(ROOT) else args.dataset),
|
||||
"indexing": index_summary,
|
||||
"metrics": metrics,
|
||||
"results": results,
|
||||
"note": "Small deterministic fixture set; measures lexical retrieval only, not answer quality or general RAG quality.",
|
||||
"note": "Small deterministic fixture set; measures configured retrieval (keyword or hybrid) and evidence presence, not answer quality or general RAG quality.",
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
@@ -26,9 +27,18 @@ def load_cases(path: Path) -> list[dict]:
|
||||
return cases
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict, timeout: float) -> dict:
|
||||
def post_json(
|
||||
url: str,
|
||||
payload: dict,
|
||||
timeout: float,
|
||||
*,
|
||||
token: str | None = None,
|
||||
) -> dict:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
request = Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = Request(url, data=body, headers=headers, method="POST")
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
@@ -70,6 +80,17 @@ def main() -> int:
|
||||
missing = set(args.case_ids) - {case["id"] for case in cases}
|
||||
if missing:
|
||||
parser.error(f"Unknown case ID(s): {', '.join(sorted(missing))}")
|
||||
base_url = args.base_url.rstrip("/")
|
||||
session = post_json(base_url + "/v1/auth/local-session", {}, args.timeout)
|
||||
token = session["access_token"]
|
||||
|
||||
def logout() -> None:
|
||||
try:
|
||||
post_json(base_url + "/v1/auth/logout", {}, args.timeout, token=token)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
atexit.register(logout)
|
||||
warmup = None
|
||||
if args.warmup:
|
||||
warmup_payload = {
|
||||
@@ -80,7 +101,7 @@ def main() -> int:
|
||||
warmup_payload["model"] = args.model
|
||||
print("Warming model before timing the evaluation...", flush=True)
|
||||
started = time.perf_counter()
|
||||
post_json(args.base_url.rstrip("/") + "/v1/chat/completions", warmup_payload, args.timeout)
|
||||
post_json(base_url + "/v1/chat/completions", warmup_payload, args.timeout, token=token)
|
||||
warmup = {"completed": True, "elapsed_seconds": round(time.perf_counter() - started, 2)}
|
||||
print(f" warmup completed in {warmup['elapsed_seconds']}s")
|
||||
results = []
|
||||
@@ -90,12 +111,12 @@ def main() -> int:
|
||||
}
|
||||
if args.model:
|
||||
payload["model"] = args.model
|
||||
url = args.base_url.rstrip("/") + ("/v1/agent/run" if case["endpoint"] == "agent" else "/v1/chat/completions")
|
||||
url = base_url + ("/v1/agent/run" if case["endpoint"] == "agent" else "/v1/chat/completions")
|
||||
print(f"[{index}/{len(cases)}] {case['id']} ({case['endpoint']})", flush=True)
|
||||
started = time.perf_counter()
|
||||
result = {"id": case["id"], "category": case["category"], "rubric": case.get("rubric", [])}
|
||||
try:
|
||||
response = post_json(url, payload, args.timeout)
|
||||
response = post_json(url, payload, args.timeout, token=token)
|
||||
answer = extract_answer(case, response)
|
||||
checks = []
|
||||
for check in case.get("checks", []):
|
||||
@@ -158,6 +179,7 @@ def main() -> int:
|
||||
"results": results,
|
||||
}
|
||||
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
logout()
|
||||
failed = sum(item["status"] != "ok" for item in results)
|
||||
passed_checks = sum(check["passed"] for item in results for check in item.get("checks", []))
|
||||
total_checks = sum(len(case.get("checks", [])) for case in cases)
|
||||
|
||||
Reference in New Issue
Block a user