diff --git a/i18n/ar/code.json b/i18n/ar/code.json
index 40d3a2e..8da8a88 100644
--- a/i18n/ar/code.json
+++ b/i18n/ar/code.json
@@ -925,5 +925,13 @@
"homepage.projects.recipePlannerAgent.summary": {
"message": "ابنِ وكيل ذكاء اصطناعي يستخدم الأدوات يقترح وجبات من المكونات المتوفرة لديك، مرتكزًا على قاعدة بيانات وصفات محلية حقيقية بدلًا من التخمين.",
"description": "Homepage project card summary"
+ },
+ "homepage.projects.studyBuddyAgent.title": {
+ "message": "بناء وكيل اختبارات رفيق المذاكرة",
+ "description": "Homepage project card title"
+ },
+ "homepage.projects.studyBuddyAgent.summary": {
+ "message": "حوّل ملاحظات دراستك الخاصة إلى اختبار تفاعلي: نموذج لغوي من مستوى مجاني يكتب أسئلة مبنية على ملاحظاتك، ثم يحكم على إجاباتك المكتوبة.",
+ "description": "Homepage project card summary"
}
}
diff --git a/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx b/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx
index e6fb270..e13042d 100644
--- a/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx
+++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx
@@ -161,5 +161,11 @@ import {mergeProjectMeta} from '@site/src/data/projects';
summary:
'ابنِ وكيل ذكاء اصطناعي يستخدم الأدوات مع deepagents من LangChain، يقترح وجبات من المكونات المتوفرة لديك، مرتكزًا على قاعدة بيانات وصفات محلية حقيقية.',
},
+ {
+ id: 'study-buddy-agent',
+ title: 'بناء وكيل اختبارات رفيق المذاكرة',
+ summary:
+ 'حوّل ملاحظات دراستك الخاصة إلى اختبار تفاعلي: نموذج لغوي من مستوى مجاني يكتب أسئلة مبنية على ملاحظاتك، ثم يحكم على إجاباتك المكتوبة.',
+ },
])}
/>
diff --git a/i18n/ar/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/_category_.json b/i18n/ar/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/_category_.json
new file mode 100644
index 0000000..62eebb6
--- /dev/null
+++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "وكيل اختبارات رفيق المذاكرة",
+ "position": 15
+}
diff --git a/i18n/ar/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/index.md b/i18n/ar/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/index.md
new file mode 100644
index 0000000..b5d5efa
--- /dev/null
+++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/index.md
@@ -0,0 +1,336 @@
+---
+id: study-buddy-agent
+title: "بناء وكيل اختبارات رفيق المذاكرة"
+sidebar_label: "وكيل اختبارات رفيق المذاكرة"
+slug: /projects/study-buddy-agent
+description: "انتقل من ساحة اللعب داخل المتصفح إلى Python فعلي: ابنِ تطبيق طرفية يحوّل ملاحظات دراستك الخاصة إلى اختبار، باستخدام نموذج لغوي بمستوى مجاني لكتابة الأسئلة والحكم على إجاباتك."
+---
+
+import ProjectProgressCheckbox from '@site/src/components/ProjectProgressCheckbox';
+import ProjectPublishedDate from '@site/src/components/ProjectPublishedDate';
+import ProjectGreeting from '@site/src/components/ProjectGreeting';
+import {StepChecklist, StepChecklistItem} from '@site/src/components/StepChecklist';
+
+# 🌍 بناء وكيل اختبارات رفيق المذاكرة
+
+
+
+
+
+كل شيء في الدورة حتى الآن عمل في ساحة لعب معزولة داخل المتصفح — لذا استطعت البدء بكتابة Python من اليوم الأول دون أي إعداد. هذا المشروع هو خطوة التخرّج: ثبّت Python فعليًا على جهازك الخاص، ثم استخدمه لبناء أداة قد تستمر فعلًا في استخدامها لمادة مختلفة تمامًا — تطبيق اختبار يقرأ ملاحظات دراستك الخاصة، ويكتب أسئلة مبنية على ما هو موجود فعلًا فيها (لا تافهات عامة)، ويختبرك سؤالًا بسؤال في الطرفية، ويجعل نموذجًا لغويًا يحكم فيما إذا كانت إجابتك المكتوبة قريبة بما يكفي، مع تغذية راجعة موجزة في الحالتين.
+
+هذا اختياري وغير مُقيَّم — مناسب جيدًا بمجرد أن تنهي Python 101؛ لا شيء من تحليل البيانات مطلوب. راجع [مشاريع من العالم الحقيقي](/docs/projects) للاطلاع على القائمة الكاملة والنامية.
+
+## 🎯 ما ستفعله
+
+1. ثبّت `uv` واحصل على مفتاح API لنموذج لغوي بمستوى مجاني.
+2. حمّل أحد ملفات ملاحظاتك الخاصة وقرر كم منها تسلّمه للنموذج كسياق.
+3. اكتب prompt يولّد أسئلة اختبار مبنية على ذلك النص تحديدًا، مع إجابة متوقعة يحتفظ بها البرنامج لنفسه.
+4. ابنِ الحلقة التفاعلية: اطرح سؤالًا، خذ إجابتك المكتوبة، اجعل النموذج يحكم عليها ويقدّم تغذية راجعة.
+5. تتبّع نتيجة متجمعة وأبلِغ عنها في النهاية.
+
+## أين تُشغّل هذا
+
+**محليًا باستخدام `uv`** هو المسار الذي تتبعه خطوات هذا الدرس، والموصى به — إنه Python فعلي يعمل على جهازك الخاص، نفس خطوة "التخرّج إلى Python فعلي" كأي مشروع آخر في هذا القسم.
+
+**GitHub Codespaces** بديل بلا إعداد إذا كنت تفضّل عدم تثبيت أي شيء محليًا بعد: افتح [مستودع الدورة كاملًا في Codespace مجاني](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node وPython و`uv` مثبّتة بالفعل، وفق `.devcontainer/devcontainer.json` الخاص بالمستودع) وشغّل نفس أوامر `uv` تمامًا من طرفية في تبويب متصفحك.
+
+**Google Colab وKaggle Notebooks أو Binder** تعمل جيدًا أيضًا — هذا المشروع مجرد سكربت طرفية يستدعي واجهة برمجية مستضافة، لا GPU ولا حزمة محلية ثقيلة مشتركة. نسخة دفتر ملاحظات جاهزة للتشغيل موجودة في [`examples/study-buddy-agent/notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/study-buddy-agent/notebook.ipynb) — تعكس نفس منطق `generate_questions()` / `judge_answer()` / `run_quiz()`، وتستخدم `input()` في خلية بنفس الطريقة التي ستستخدمها في طرفية، وتضمّن أحد ملفات الملاحظات النموذجية مباشرةً لذا تعمل دون حاجة إلى رفع ملف. أطلقها بأحد الشارات أدناه:
+
+[](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/study-buddy-agent/notebook.ipynb)
+[](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/study-buddy-agent/notebook.ipynb)
+[](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fstudy-buddy-agent%2Fnotebook.ipynb)
+
+إنها طريقة أقل دقة لتجربة الأمر من مشروع محلي فعلي (لا بنية ملفات حقيقية، لا ملفات `.py` منفصلة)، لكنها طريقة معقولة لتجربة الفكرة بسرعة.
+
+## الإعداد
+
+كل ما تحتاجه قبل الخطوة 1 — تثبيت `uv`، وإنشاء المشروع، والحصول على مفتاح API — موجود هنا، كله مقدمًا، حتى تركز الخطوات أدناه على منطق الاختبار فقط.
+
+### ثبّت uv
+
+`uv` أداة واحدة تحل محل السلسلة المعتادة "ثبّت Python، ثم ثبّت pip، ثم ثبّت أداة بيئة افتراضية، ثم ثبّت الحزم" — يمكنها تثبيت وإدارة إصدارات Python بنفسها، إلى جانب تبعيات مشروعك.
+
+**macOS / Linux** (الطرفية):
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+**Windows** (PowerShell):
+
+```powershell
+powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
+```
+
+أغلق طرفيتك وأعد فتحها، ثم تأكد من أنها ثُبِّتت:
+
+```bash
+uv --version
+```
+
+### أنشئ المشروع
+
+```bash
+uv init study-buddy-agent
+cd study-buddy-agent
+uv add openai python-dotenv
+```
+
+`uv init` ينشئ مشروعًا صغيرًا (`pyproject.toml` يتتبع تبعياتك) و`uv add` يثبّت الحزم في بيئة معزولة لذلك المشروع — دون إعداد بيئة افتراضية يدويًا. `openai` هي مكتبة العميل التي يستخدمها هذا الدرس (نماذج GitHub، المزوّد الافتراضي المقترح أدناه، تكشف عن واجهة برمجية متوافقة مع OpenAI)؛ `python-dotenv` تتيح لك إبقاء مفتاح API في ملف `.env` محلي بدلًا من تصديره عبر `export` في كل جلسة.
+
+### احصل على مفتاح API مجاني لنموذج لغوي
+
+**اختر أي مزوّد تفضّله** — لا يتطلب أي منها بطاقة ائتمان وقت كتابة هذا، ولا تفضّل هذه الدورة واحدًا على آخر. السكربت المثال في مستودع الدورة ([`examples/study-buddy-agent/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/study-buddy-agent)) يستخدم نماذج GitHub افتراضيًا؛ التحويل إلى مزوّد آخر تغيير صغير وموثّق جيدًا.
+
+| المزوّد | أين تحصل على المفتاح | لماذا قد تختاره |
+|---|---|---|
+| **نماذج GitHub** *(الافتراضي المقترح)* | [github.com/settings/tokens](https://github.com/settings/tokens) — رمز وصول شخصي بنطاق `models: read` | لا تسجيل منفصل — لديك بالفعل حساب GitHub. حدود مستوى مجاني أكثر سخاءً من حدود Gemini. |
+| Gemini | [Google AI Studio](https://aistudio.google.com/) | الخيار الأكثر تداولًا. |
+| Groq | [console.groq.com/keys](https://console.groq.com/keys) | استدلال سريع، مستوى مجاني سخي، بلا بطاقة. |
+| Mistral | [console.mistral.ai/api-keys](https://console.mistral.ai/api-keys) | واحدة من أكثر الحصص المجانية الدائمة سخاءً. |
+| Cerebras | [cloud.cerebras.ai](https://cloud.cerebras.ai/) | حجم رموز يومي مرتفع، بلا بطاقة. |
+| OpenRouter | [openrouter.ai/keys](https://openrouter.ai/keys) | واجهة برمجية واحدة، نماذج مجانية كثيرة — جيد لمقارنة المزوّدين. |
+
+أيًا كان ما تختاره، العملية هي نفسها:
+
+1. سجّل الدخول وأنشئ مفتاح API على موقع ذلك المزوّد.
+2. **لا تلصق هذا المفتاح أبدًا مباشرةً في الكود أو تثبّته في مستودع.** ضعه في ملف `.env` بدلًا من ذلك:
+
+```bash
+# .env
+GITHUB_TOKEN=your-key-here
+```
+
+يقرأ `python-dotenv` هذا الملف إلى `os.environ` تلقائيًا، نفس النمط المستخدم طوال مشروعي [وكيل الذكاء الاصطناعي](/docs/projects/ai-agent) و[تطبيق RAG](/docs/projects/rag-notes) لو نفّذت أيًّا منهما. مفتاح API سرّ، تمامًا مثل كلمة المرور — أي شخص يملكه يمكنه استخدام حصة حسابك.
+
+:::tip[ملف `.env` غالبًا أكثر ملاءمة من export]
+بدلًا من تصدير مفتاح عبر `export` في كل جلسة طرفية جديدة، ضعه في ملف `.env` داخل مجلد مشروعك (انظر `.env.example` في مثال المستودع) وحمّله بـ`load_dotenv()`، مُستدعاةً مرة واحدة قرب أعلى السكربت.
+:::
+
+مع `uv` و`openai` و`python-dotenv` ومفتاح في `.env`، اكتمل الإعداد — كل شيء من هنا منطق اختبار.
+
+## الخطوة 1: حمّل ملاحظاتك واختر استراتيجية السياق
+
+ضع ملف `.txt` أو `.md` من ملاحظات دراستك الخاصة في مكان ما داخل مشروعك — مجلد `notes/`، نفس اصطلاح [مشروع تطبيق RAG](/docs/projects/rag-notes)، مكان معقول. قراءته ليست جديدة عليك:
+
+```python
+from pathlib import Path
+
+notes_text = Path("notes/cell-biology.txt").read_text(encoding="utf-8")
+```
+
+هنا قرار التصميم الذي يطلب منك هذا المشروع اتخاذه صراحةً، بدلًا من تخطّيه: **كم من ملاحظاتك يجب أن يرى النموذج فعلًا؟**
+
+- **الخيار A — أطعم الملف كاملًا كسياق.** أبسط نهج ممكن: اقرأ ملفًا واحدًا، سلّم نصه بالكامل للنموذج في الـprompt، انتهى الأمر. يعمل هذا جيدًا ما دام ملف واحد يتسع براحة في نافذة سياق النموذج — بضعة آلاف من الكلمات ليست مشكلة إطلاقًا لأي نموذج مجاني حديث.
+- **الخيار B — جزّئ، وضمّن، واسترجع**، تمامًا كما يفعل [مشروع تطبيق RAG](/docs/projects/rag-notes): اقسّم ملاحظاتك إلى قطع صغيرة، وضمّنها محليًا، واسترجع فقط الأكثر صلة لكل سؤال. يتوسع هذا ليشمل مجلد ملاحظات بعشرات الملفات الطويلة التي لن تتسع أبدًا في prompt واحد.
+
+**يختار هذا الدرس الخيار A** وهو صريح بشأن المقايضة: إنه أقل قابلية للتوسع، لكنه أبسط درسًا كاملًا في الكتابة والقراءة وتصحيح الأخطاء — لا نموذج تضمين، لا بحث متجهي، لا خطوة بناء فهارس منفصلة، مجرد سلسلة نصية. تلك المقايضة تستحق أن تُسمّى بصوت عالٍ، نفس مبدأ التأسيس كمشروع تطبيق RAG في الحالتين: يجب أن يأتي سؤال الاختبار الجيد من نص أُعطي للنموذج فعلًا، لا من نص يخمّن أنه قد يكون ذا صلة من بيانات التدريب. لو تجاوزت ملاحظاتك ملفًا واحدًا، لا تخترع استرجاعًا من جديد — أعد استخدام `retrieve.py` من مثال مشروع تطبيق RAG وبدّل prompt الخطوة 2 لاستخدام قطع مُسترجَعة بدلًا من ملف كامل.
+
+**✅ قائمة التحقق**
+
+
+لديك مجلد `notes/` فيه ملف `.txt`/`.md` حقيقي واحد على الأقل من ملاحظات دراستك الخاصة.
+قراءة الملف وطباعة طوله تُظهر عدد أحرف حقيقيًا، لا `0` أو خطأ.
+تستطيع أن تشرح، في جملة واحدة، لماذا يغذّي هذا الدرس الملف كاملًا للنموذج بدلًا من استرجاع قطع.
+
+
+**🤔 سؤال (أسئلة) سقراطي(ة)**
+
+- لو كان ملف ملاحظاتك بطول 50 صفحة بدلًا من صفحة واحدة، ما الذي سيفشل تحديدًا في الخيار A أولًا — خطأ، أو prompt مقتطع، أو شيء أكثر دهاءً مثل النموذج الذي يستخدم بداية الملف فقط فعلًا؟
+- خطوة تقسيم مشروع تطبيق RAG موجودة لجعل كل قطعة مضمّنة *محددة*. هل فقدان التقسيم هنا يفقد تلك الخصوصية، أم أن تسليم النموذج الملف كاملًا يمنحه فعلًا *أكثر* ليعمل به؟ تحت أي ظروف يكون كل إجابة صحيحة؟
+
+## الخطوة 2: ولّد أسئلة اختبار مبنية على ملاحظاتك
+
+اطلب من النموذج عددًا ثابتًا من الأسئلة، كل واحد مقترنًا بإجابة متوقعة — وكن صريحًا في الـprompt أن كلاهما يجب أن يأتي من النص المحدد الذي تسلّمه، لا من المعرفة العامة بالموضوع:
+
+```python
+import json
+
+GENERATE_PROMPT_TEMPLATE = """You are a study-buddy quiz generator. Read the
+study notes below and write exactly {num_questions} quiz questions that can
+ONLY be answered correctly by someone who has read THESE SPECIFIC notes --
+not generic questions about the general subject. Base every question and
+every expected answer strictly on facts stated in the text.
+
+Reply with ONLY a JSON array, no other text, in this exact shape:
+[
+ {{"question": "...", "expected_answer": "..."}},
+ ...
+]
+
+Study notes:
+{notes_text}
+"""
+
+def generate_questions(notes_text: str, num_questions: int = 5) -> list[dict]:
+ prompt = GENERATE_PROMPT_TEMPLATE.format(num_questions=num_questions, notes_text=notes_text)
+ response = client.chat.completions.create(
+ model=MODEL,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ raw = response.choices[0].message.content.strip()
+ raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
+ return json.loads(raw)
+```
+
+تفصيلان يستحقان الملاحظة:
+
+- **يُولَّد `expected_answer` الآن، لكنه لا يُعرض أبدًا على الطالب قبل إجابته.** يحتفظ البرنامج به في الذاكرة (في القاموس الذي تُرجعه `generate_questions`) فقط لكي يكون لدى الخطوة 3 شيء تحكم عليه لاحقًا — هذه نفس فكرة "مؤسَّس، لا مُخمَّن" كسياق مشروع تطبيق RAG المُسترجَع، لكن مستخدمةً للتحقق من إجابة بدلًا من كتابة واحدة.
+- **طلب الرد بـJSON فقط ثم تحليله نمط هش لكنه شائع.** يلف النماذج أحيانًا إجابتهم في سياج كود ` ```json ` حتى عند إخبارهم بعدم ذلك — استدعاءات `removeprefix`/`removesuffix` أعلاه تجرّد ذلك قبل تشغيل `json.loads`. لو فشل التحليل مع ذلك، طباعة الاستجابة الخام قبل التحليل أسرع طريقة لرؤية ما عاد فعلًا.
+
+:::tip[اطلب أسئلة أكثر مما تحتاج، لو كانت الجودة غير ثابتة]
+النماذج المجانية الصغيرة تنتج أحيانًا سؤالًا غامضًا أو غريب الصياغة. لو لاحظت هذا على ملاحظاتك الخاصة، إصلاح بسيط دون أي كود جديد هو طلب بضعة أسئلة إضافية في الـprompt والاحتفاظ بأول `N` فقط — أو مجرد إعادة تشغيل التوليد، لأنه استدعاء API واحد.
+:::
+
+**✅ قائمة التحقق**
+
+
+تُرجع `generate_questions(notes_text)` قائمة Python من قواميس، كل واحد بمفتاحي `"question"` و`"expected_answer"`.
+قراءة اثنين من الأسئلة المولّدة، تشير بوضوح إلى تفاصيل من ملف ملاحظاتك، لا حقائق عامة عن الموضوع كان محرك بحث قادرًا على كتابتها.
+تفهم لماذا يُولَّد `expected_answer` لكن لا يُطبع على الشاشة بعد.
+
+
+**🤔 سؤال (أسئلة) سقراطي(ة)**
+
+- لو سلّمت النموذج ملف ملاحظات عن موضوع يعرفه جيدًا أصلًا من التدريب (لنقل، البناء الضوئي الأساسي)، كيف ستُخبر هل سؤال مولَّد مؤسَّس فعلًا على *ملاحظاتك* مقابل معرفة النموذج السابقة؟ هل توجد طريقة لاختبار هذا؟
+- ماذا سيحدث لجودة الأسئلة لو كان `notes_text` فارغًا أو مجرد جملة قصيرة واحدة؟ جرّبها — هل ينتج النموذج استجابة أنيقة أم شيئًا مكسورًا بوضوح؟
+
+## الخطوة 3: ابنِ حلقة الاختبار التفاعلية
+
+الآن الجزء الذي يجعل من هذا اختبارًا لا مجرد مولّد أسئلة: اطرح كل سؤال، اقرأ إجابة الطالب المكتوبة، واجعل النموذج يحكم عليها — لن تطابق الإجابات النص الحر الإجابة المتوقعة كلمة بكلمة، لذا مقارنة سلسلة دقيقة (`==`) ستصحّف كل شيء تقريبًا.
+
+```python
+JUDGE_PROMPT_TEMPLATE = """You are grading a student's quiz answer. Judge
+whether the student's answer is correct, partially correct, or incorrect,
+compared to the expected answer below -- the student won't phrase it
+identically, so judge on meaning, not exact wording.
+
+Question: {question}
+Expected answer: {expected_answer}
+Student's answer: {student_answer}
+
+Reply with ONLY JSON, no other text, in this exact shape:
+{{"verdict": "correct" | "close" | "incorrect", "feedback": "one brief, encouraging sentence"}}
+"""
+
+def judge_answer(question: str, expected_answer: str, student_answer: str) -> dict:
+ prompt = JUDGE_PROMPT_TEMPLATE.format(
+ question=question, expected_answer=expected_answer, student_answer=student_answer
+ )
+ response = client.chat.completions.create(
+ model=MODEL,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ raw = response.choices[0].message.content.strip()
+ raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
+ return json.loads(raw)
+
+
+def run_quiz(questions: list[dict]) -> None:
+ score = 0
+ for i, item in enumerate(questions, start=1):
+ print(f"\nQuestion {i}/{len(questions)}: {item['question']}")
+ student_answer = input("Your answer: ").strip()
+
+ result = judge_answer(item["question"], item["expected_answer"], student_answer)
+ verdict = result.get("verdict", "incorrect")
+ feedback = result.get("feedback", "")
+
+ if verdict == "correct":
+ score += 1
+ print(f"✅ Correct! {feedback}")
+ elif verdict == "close":
+ score += 0.5
+ print(f"🟡 Close. {feedback}")
+ else:
+ print(f"❌ Not quite. {feedback}")
+ print(f" Expected answer: {item['expected_answer']}")
+
+ print(f"\nFinal score: {score}/{len(questions)}")
+```
+
+الحكم ثلاثي الاتجاه (`correct` / `close` / `incorrect`) أكثر تسامحًا عمدًا من صواب/خطأ ثنائي — طالب لديه الفكرة الصحيحة لكنه أخطأ تفصيلًا يحصل على درجة جزئية وتغذية راجعة مفيدة، بدلًا من "خطأ" مسطّحة لا تقول لماذا.
+
+:::tip[input() تحجب التنفيذ حتى يضغط الطالب Enter]
+`input("Your answer: ")` يوقف السكربت كله عند ذلك السطر حتى تكتب شيئًا وتضغط Enter — تمامًا مثل `input()` في Python 101، لكن الآن جالس داخل حلقة تحدث أيضًا استدعاءات شبكية قبل وبعد. لو بدا أن الطرفية معلقة بعد طباعة سؤال، فهذا طبيعي: إنها تنتظرك، لا تنتظر الـAPI.
+:::
+
+**✅ قائمة التحقق**
+
+
+`run_quiz(questions)` تطبع سؤالًا واحدًا في كل مرة وتنتظر فعلًا إدخالًا مكتوبًا قبل المتابعة.
+إجابة صحيحة عمدًا تُعلَّم صحيحة، وإجابة خاطئة عمدًا تُعلَّم خاطئة، مع عرض الإجابة المتوقعة.
+إجابة صحيحة تقريبًا لكن ليست بنفس الصياغة (مثل إعادة صياغة) تحصل على حكم معقول، لا "incorrect" ظالمة.
+
+
+**🤔 سؤال (أسئلة) سقراطي(ة)**
+
+- لماذا تحكم باستدعاء LLM *ثانٍ* لكل سؤال بدلًا من طلب أن يولّد النموذج السؤال والإجابة المتوقعة *و*الحكم كلها في استدعاء واحد وقت توليد الاختبار؟ ما الذي سيُخطئه ذلك النهج، بما أن الطالب لم يجب بعد وقت التوليد؟
+- حكم `"close"` يمنح نصف درجة. ما حالة يجب أن تكون إجابة الطالب فيها بوضوح "close" بدلًا من صحيحة تمامًا أو خاطئة تمامًا — وهل ستقع إجابتك الخاصة على سؤال حقيقي من ملاحظاتك هناك؟
+
+## الخطوة 4: تتبّع النتيجة وشغّلها من البداية إلى النهاية
+
+`run_quiz` أعلاه يتتبع `score` بالفعل أثناء تقدمه ويطبع سطر `score/total` نهائيًا بمجرد انتهاء الحلقة. اربط كل شيء معًا في `main()`:
+
+```python
+def main() -> None:
+ notes_text = Path("notes/cell-biology.txt").read_text(encoding="utf-8")
+
+ print("Generating questions...")
+ questions = generate_questions(notes_text)
+ print(f"Got {len(questions)} questions. Let's go!")
+
+ run_quiz(questions)
+
+
+if __name__ == "__main__":
+ main()
+```
+
+شغّله:
+
+```bash
+uv run python study_buddy.py
+```
+
+يجب أن ترى توقفًا قصيرًا "Generating questions..." (استدعاء API واحد)، ثم خمسة أسئلة واحدًا تلو الآخر، كل واحد ينتظر إجابتك المكتوبة قبل المضي، وينتهي بسطر نتيجة نهائي مثل `Final score: 3.5/5`.
+
+**✅ قائمة التحقق**
+
+
+`uv run python study_buddy.py` يعمل من البداية إلى النهاية: التوليد، ثم كل الأسئلة، ثم سطر نتيجة نهائي.
+رقم النتيجة النهائية يطابق ما تتوقعه من إجاباتك الخاصة (صحيح = +1، قريب = +0.5، خطأ = +0).
+تشغيله مجددًا على نفس ملف الملاحظات ينتج مجموعة *مختلفة* من الأسئلة — مؤكدًا أن التوليد ليس مُرمَّزًا كودًا ثابتًا أو مخزّنًا في ذاكرة تخزين مؤقت.
+
+
+**🤔 سؤال (أسئلة) سقراطي(ة)**
+
+- لو شغّلت السكربت كله مرتين متتاليتين على نفس ملف الملاحظات، هل تتوقع نفس الأسئلة الخمسة بالضبط في المرتين؟ لماذا أو لماذا لا، بالنظر إلى كيفية استدعاء `generate_questions` للنموذج؟
+- الآن، استدعاء `judge_answer` سيئ (فشل تحليل، خطأ شبكي) سيعطّل الاختبار كله في منتصفه، مُضيّعًا تقدم الطالب في الأسئلة المتبقية. ما التغيير الأدنى في `run_quiz` الذي سيسمح للاختبار بالمتابعة بعد حكم سيئ واحد بدلًا من التوقف تمامًا؟
+
+## ⚠️ مآزق شائعة
+
+- **الملاحظات الرقيقة تنتج أسئلة رقيقة.** لو كان ملف ملاحظاتك مجرد بضع نقاط قصيرة، لدى النموذج القليل جدًا ليُرسي عليه خمسة أسئلة مميزة، وستحصل على أسئلة متكررة أو سهلة جدًا ("ما اسم...؟"). الملاحظات الأكثر تفصيلًا بنمط نصي تنتج أسئلة أفضل بشكل ملحوظ — هذا يعكس درس تقسيم مشروع تطبيق RAG: نص إدخال أفضل يعني نتيجة أفضل، لا prompt أذكى.
+- **الحكم يمكن أن يكون صارمًا جدًا أو متساهلًا جدًا.** نموذج مجاني صغير يحكم على إجابات نص حر ليس أداة دقيقة — قد يعلّم إجابة صحيحة لكنها غريبة الصياغة كخطأ، أو يمرر إجابة تفتقد فعلًا تفصيلًا مفتاحيًا. لو لاحظت انحيازًا ثابتًا، شدّد صياغة `JUDGE_PROMPT_TEMPLATE` (مثل "الدرجة الجزئية تُحتسب فقط إذا كانت حقيقة محددة واحدة على الأقل صحيحة") بدلًا من محاولة الالتفاف حوله في Python.
+- **حدود المعدل من استدعاءين لكل سؤال.** على عكس إجابة RAG بطلقة واحدة، يصنع هذا السكربت استدعاءين للنموذج *لكل سؤال* بحلول وقت إنهاء اختبار — واحد للتوليد (مرة واحدة، لكل اختبار) وواحد للحكم (مرة واحدة، لكل سؤال). اختبار من 5 أسئلة هو 6 استدعاءات إجمالًا؛ شغّل عدة اختبارات متتالية على مستوى مجاني وقد تصادف خطأ حد معدل 429. هذا ليس خطأً — انظر [مشروع وكيل الذكاء الاصطناعي](/docs/projects/ai-agent#التعامل-مع-حدود-المعدل) لنفس النمط ونهج إعادة محاولة يمكنك نسخه.
+- **JSON مشوّه من النموذج يكسر `json.loads`.** حتى مع تعليمات صريحة "رد بـJSON فقط"، يضيف نموذج أحيانًا جملة شاردة قبل أو بعد الـJSON، أو يترك فاصلة زائدة. لو صادفت `JSONDecodeError`، اطبع الاستجابة الخام قبل تحليلها — يكفي ذلك دائمًا تقريبًا لترى بالضبط ما الخطأ وتعدّل الـprompt.
+
+## ما بنيته للتو
+
+خط أنابيب صغير لكنه كامل من "ولّد، ثم تفاعل، ثم صحّح": استدعاء LLM واحد يحوّل ملاحظاتك الخاصة إلى أسئلة مؤسَّسة بإجابات لا يراها إلا البرنامج، حلقة تجمع إجاباتك المكتوبة، واستدعاء LLM ثانٍ يحكم على كل واحدة بالمعنى لا بالصياغة الدقيقة، مع نتيجة متجمعة تُحتسب عبر الجلسة كلها. لم يُزوَّر أي شيء هنا في لعبة لا تعمّم — وجّهه إلى ملف ملاحظات مفيد فعلًا لمادة أخرى تأخذها، وسيصبح أداة دراسة حقيقية، لا مجرد تمرين دورة.
+
+## إلى أين تذهب من هنا
+
+- بمجرد أن يتوقف ملف ملاحظات واحد عن الكفاية — ملاحظات فصل دراسي كامل عبر ملفات كثيرة — أعد استخدام خط أنابيب [مشروع تطبيق RAG](/docs/projects/rag-notes) `prepare_notes.py`/`build_index.py`/`retrieve.py`: استرجع القطع الأكثر صلة لـ*موضوع* تريد أن تُختبر عليه، وأطعمها إلى `generate_questions` بدلًا من ملف واحد كامل.
+- تتبّع الأسئلة الخاطئة عبر التشغيلات (اكتبها في ملف JSON صغير) وابنِ وضع "راجع نقاط ضعفي" الذي يعيد اختبارك تحديدًا على المواضيع التي أخطأت فيها سابقًا.
+- أضف إعداد صعوبة إلى `GENERATE_PROMPT_TEMPLATE` ("أسئلة استرجاع سهلة" مقابل "أسئلة تتطلب ربط فكرتين من الملاحظات") وقارن كم يشعر الوضع الأصعب صعوبةً فعلًا.
+- أعد النظر في محتوى `try`/`except` الإضافي من Python 101 — لف `judge_answer` بحيث لا ينهي استجابة مشوّهة واحدة الاختبار كله (انظر السؤال السقراطي في الخطوة 4) هو بالضبط ذلك النمط.
+
+## شارك مشروعك مع الصف
+
+بنيت شيئًا فخورًا به؟ [`examples/student-projects/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/student-projects) معرض لمشاريع قدّمها طلاب آخرون — وملف README الخاص به يحتوي شرحًا كاملًا وودودًا للمبتدئين لإضافة مشروعك عبر **pull request**، حتى لو لم تستخدم git من قبل قط: عمل fork للمستودع، وإنشاء فرع، وتثبيت ملفاتك، وفتح الـPR، خطوة بخطوة. لا يُفترض أي خبرة سابقة بـgit.
+
+مرحبًا بك في كتابة Python خارج المتصفح. 🎓
+
+
diff --git a/i18n/es/code.json b/i18n/es/code.json
index 207f767..870d585 100644
--- a/i18n/es/code.json
+++ b/i18n/es/code.json
@@ -925,5 +925,13 @@
"homepage.projects.recipePlannerAgent.summary": {
"message": "Construye un agente de IA que usa herramientas que sugiere comidas a partir de los ingredientes que tienes a la mano, anclado a una base de datos de recetas local real en lugar de adivinar.",
"description": "Homepage project card summary"
+ },
+ "homepage.projects.studyBuddyAgent.title": {
+ "message": "Construye un Agente de Cuestionarios de Compañero de Estudio",
+ "description": "Homepage project card title"
+ },
+ "homepage.projects.studyBuddyAgent.summary": {
+ "message": "Convierte tus propias notas de estudio en un cuestionario interactivo: un LLM de nivel gratuito escribe preguntas fundamentadas en tus notas, luego juzga tus respuestas escritas.",
+ "description": "Homepage project card summary"
}
}
diff --git a/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx b/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx
index d99acee..9b9586d 100644
--- a/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx
+++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx
@@ -161,5 +161,11 @@ Son opcionales y no calificados. Explóralos en cualquier momento — la introdu
summary:
'Construye un agente de IA que usa herramientas con los deepagents de LangChain, que sugiere comidas a partir de los ingredientes que tienes a la mano, anclado a una base de datos de recetas local real.',
},
+ {
+ id: 'study-buddy-agent',
+ title: 'Construye un Agente de Cuestionarios de Compañero de Estudio',
+ summary:
+ 'Convierte tus propias notas de estudio en un cuestionario interactivo: un LLM de nivel gratuito escribe preguntas fundamentadas en tus notas, luego juzga tus respuestas escritas.',
+ },
])}
/>
diff --git a/i18n/es/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/_category_.json b/i18n/es/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/_category_.json
new file mode 100644
index 0000000..1613013
--- /dev/null
+++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Agente de Cuestionarios de Compañero de Estudio",
+ "position": 15
+}
diff --git a/i18n/es/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/index.md b/i18n/es/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/index.md
new file mode 100644
index 0000000..c4a14d0
--- /dev/null
+++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/index.md
@@ -0,0 +1,336 @@
+---
+id: study-buddy-agent
+title: "Construye un Agente de Cuestionarios de Compañero de Estudio"
+sidebar_label: "Agente de Cuestionarios de Compañero de Estudio"
+slug: /projects/study-buddy-agent
+description: "Pasa del playground dentro del navegador al Python real: construye una app de terminal que convierte tus propias notas de estudio en un cuestionario, usando un LLM de nivel gratuito para escribir las preguntas y juzgar tus respuestas."
+---
+
+import ProjectProgressCheckbox from '@site/src/components/ProjectProgressCheckbox';
+import ProjectPublishedDate from '@site/src/components/ProjectPublishedDate';
+import ProjectGreeting from '@site/src/components/ProjectGreeting';
+import {StepChecklist, StepChecklistItem} from '@site/src/components/StepChecklist';
+
+# 🌍 Construye un Agente de Cuestionarios de Compañero de Estudio
+
+
+
+
+
+Todo en el curso hasta ahora corrió en un playground sandbox, dentro del navegador — para que pudieras empezar a escribir Python desde el día uno con cero configuración. Este proyecto es el paso de graduación: instala Python de verdad en tu propia máquina, y luego úsalo para construir una herramienta que podrías seguir usando de verdad para una clase completamente distinta — una app de cuestionario que lee tus propias notas de estudio, escribe preguntas fundamentadas en lo que realmente está en ellas (no trivia genérica), te examina una pregunta a la vez en la terminal, y tiene un modelo de lenguaje que juzga si tu respuesta escrita se acerca lo suficiente, con retroalimentación breve en cualquier caso.
+
+Esto es opcional y no calificado — una buena opción una vez que hayas terminado Python 101; nada de Data Analysis es requerido. Consulta [Proyectos del mundo real](/docs/projects) para la lista completa y creciente.
+
+## 🎯 Lo que harás
+
+1. Instalar `uv` y obtener una clave de API de LLM de nivel gratuito.
+2. Cargar uno de tus propios archivos de notas y decidir cuánto de él darle al modelo como contexto.
+3. Escribir un prompt que genere preguntas de cuestionario fundamentadas en ese texto específico, junto con una respuesta esperada que el programa mantiene en secreto.
+4. Construir el bucle interactivo: hacer una pregunta, tomar tu respuesta escrita, hacer que el modelo la juzgue y dé retroalimentación.
+5. Llevar un puntaje acumulado y reportarlo al final.
+
+## Dónde ejecutar esto
+
+**Localmente con `uv`** es el camino que siguen los pasos de esta lección, y el recomendado — es Python real corriendo en tu propia máquina, el mismo movimiento de "gradúate a Python real" que cualquier otro proyecto de esta sección.
+
+**GitHub Codespaces** es una alternativa de configuración cero si prefieres no instalar nada localmente todavía: abre [todo el repositorio del curso en un Codespace gratuito](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python y `uv` ya están instalados, según el `.devcontainer/devcontainer.json` del repositorio) y ejecuta exactamente los mismos comandos `uv` desde una terminal en tu pestaña del navegador.
+
+**Google Colab, Kaggle Notebooks, o Binder** funcionan bien también — este proyecto es solo un script de terminal que llama a una API alojada, sin GPU ni paquete local pesado involucrado. Una versión lista para ejecutar en notebook vive en [`examples/study-buddy-agent/notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/study-buddy-agent/notebook.ipynb) — refleja la misma lógica de `generate_questions()` / `judge_answer()` / `run_quiz()`, usa `input()` en una celda de la misma manera que lo harías en una terminal, e incrusta uno de los archivos de notas de muestra directamente para que funcione sin necesidad de subir un archivo. Lánzala con uno de los badges de abajo:
+
+[](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/study-buddy-agent/notebook.ipynb)
+[](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/study-buddy-agent/notebook.ipynb)
+[](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fstudy-buddy-agent%2Fnotebook.ipynb)
+
+Es una forma de menor fidelidad de vivir la experiencia que un proyecto local real (sin estructura de archivos real, sin archivos `.py` separados), pero es una manera razonable de probar la idea rápidamente.
+
+## Configuración
+
+Todo lo que necesitas antes del Paso 1 — instalar `uv`, crear el proyecto, y obtener una clave de API — vive aquí, todo por adelantado, para que los pasos de abajo puedan enfocarse puramente en la lógica del cuestionario.
+
+### Instalar `uv`
+
+`uv` es una sola herramienta que reemplaza la cadena habitual de "instala Python, luego instala pip, luego instala una herramienta de entorno virtual, luego instala paquetes" — puede instalar y gestionar versiones de Python por sí misma, junto con las dependencias de tu proyecto.
+
+**macOS / Linux** (terminal):
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+**Windows** (PowerShell):
+
+```powershell
+powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
+```
+
+Cierra y vuelve a abrir tu terminal, luego confirma que se instaló:
+
+```bash
+uv --version
+```
+
+### Crear el proyecto
+
+```bash
+uv init study-buddy-agent
+cd study-buddy-agent
+uv add openai python-dotenv
+```
+
+`uv init` crea un proyecto pequeño (un `pyproject.toml` que rastrea tus dependencias) y `uv add` instala paquetes en un entorno aislado para ese proyecto — sin configuración manual de entorno virtual. `openai` es la biblioteca cliente que esta lección usa (GitHub Models, el proveedor predeterminado sugerido abajo, expone una API compatible con OpenAI); `python-dotenv` te permite mantener tu clave de API en un archivo `.env` local en lugar de `export`-arla en cada sesión.
+
+### Obtén una clave de API de IA gratuita
+
+**Elige el proveedor que prefieras** — ninguno requiere tarjeta de crédito al momento de escribir esto, y este curso no favorece a uno sobre otro. El script de ejemplo en el repositorio del curso ([`examples/study-buddy-agent/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/study-buddy-agent)) usa GitHub Models por defecto; cambiar a otro proveedor es un cambio pequeño y bien documentado.
+
+| Proveedor | Dónde obtener una clave | Por qué podrías elegirlo |
+|---|---|---|
+| **GitHub Models** *(predeterminado sugerido)* | [github.com/settings/tokens](https://github.com/settings/tokens) — un token de acceso personal con el alcance `models: read` | Sin registro separado — ya tienes una cuenta de GitHub. Límites de nivel gratuito más generosos que los de Gemini. |
+| Gemini | [Google AI Studio](https://aistudio.google.com/) | La opción más comúnmente referenciada. |
+| Groq | [console.groq.com/keys](https://console.groq.com/keys) | Inferencia rápida, nivel gratuito generoso, sin tarjeta. |
+| Mistral | [console.mistral.ai/api-keys](https://console.mistral.ai/api-keys) | Una de las cuotas gratuitas permanentes más generosas. |
+| Cerebras | [cloud.cerebras.ai](https://cloud.cerebras.ai/) | Alto volumen diario de tokens, sin tarjeta. |
+| OpenRouter | [openrouter.ai/keys](https://openrouter.ai/keys) | Una API, muchos modelos gratuitos — bueno para comparar proveedores. |
+
+Sea cual sea que elijas, el proceso es el mismo:
+
+1. Inicia sesión y genera una clave de API en el sitio de ese proveedor.
+2. **Nunca pegues esta clave directamente en el código ni la subas a un repositorio.** Ponla en un archivo `.env` en su lugar:
+
+```bash
+# .env
+GITHUB_TOKEN=your-key-here
+```
+
+`python-dotenv` lee este archivo hacia `os.environ` automáticamente, el mismo patrón usado a lo largo de los proyectos [de Agente de IA](/docs/projects/ai-agent) y [de App RAG](/docs/projects/rag-notes) si has hecho alguno de esos. Una clave de API es un secreto, exactamente como una contraseña — cualquiera que la tenga puede usar la cuota de tu cuenta.
+
+:::tip[Un archivo `.env` es a menudo más conveniente que export]
+En lugar de hacer `export` de una clave en cada nueva sesión de terminal, ponla en un archivo `.env` en tu carpeta de proyecto (ver el `.env.example` del ejemplo del repo) y cárgala con `load_dotenv()`, llamada una vez cerca de la parte superior de tu script.
+:::
+
+Con `uv`, `openai`, `python-dotenv`, y una clave en `.env`, la configuración está hecha — todo de aquí en adelante es lógica de cuestionario.
+
+## Paso 1: Carga tus notas y elige una estrategia de contexto
+
+Pon un archivo `.txt` o `.md` de tus propias notas de estudio en algún lugar de tu proyecto — una carpeta `notes/`, misma convención que el [proyecto RAG](/docs/projects/rag-notes), es un lugar razonable. Leerlo no es nada nuevo:
+
+```python
+from pathlib import Path
+
+notes_text = Path("notes/cell-biology.txt").read_text(encoding="utf-8")
+```
+
+Aquí está la decisión de diseño que este proyecto te pide tomar explícitamente, en lugar de saltártela: **¿cuánto de tus notas debería ver el modelo realmente?**
+
+- **Opción A — alimenta el archivo completo como contexto.** El enfoque más simple posible: lee un archivo, entrega su texto completo al modelo en el prompt, listo. Esto funciona genial siempre que un solo archivo quepa cómodamente en la ventana de contexto del modelo — unas pocas miles de palabras no es ningún problema para cualquier modelo gratuito moderno.
+- **Opción B — fragmentar, incrustar y recuperar**, exactamente como hace el [proyecto RAG](/docs/projects/rag-notes): divide tus notas en piezas pequeñas, incrústalas localmente, y recupera solo las más relevantes para cada pregunta. Esto escala a una carpeta de notas con docenas de archivos largos que nunca cabrían en un solo prompt.
+
+**Esta lección elige la Opción A** y es explícita sobre la compensación: es menos escalable, pero es una lección completa más simple de escribir, leer y depurar — sin modelo de embedding, sin búsqueda vectorial, sin paso separado de construcción de índice, solo una cadena. Esa compensación vale la pena nombrarla en voz alta, el mismo principio de fundamentación que el proyecto RAG de cualquier manera: una buena pregunta de cuestionario tiene que venir de texto que el modelo realmente recibió, no texto que está adivinando que podría ser relevante de los datos de entrenamiento. Si tus propias notas superan un solo archivo, no reinventes la recuperación — reutiliza `retrieve.py` del ejemplo del proyecto RAG y cambia el prompt del Paso 2 para usar fragmentos recuperados en lugar de un archivo completo.
+
+**✅ Lista de verificación**
+
+
+Tienes una carpeta `notes/` con al menos un archivo `.txt`/`.md` real de tus propias notas de estudio en ella.
+Leer el archivo e imprimir su longitud muestra un conteo de caracteres real, no `0` o un error.
+Puedes explicar, en una oración, por qué esta lección alimenta el archivo completo al modelo en lugar de recuperar fragmentos.
+
+
+**🤔 Pregunta(s) socrática(s)**
+
+- Si tu archivo de notas tuviera 50 páginas de largo en lugar de una página, ¿qué fallaría específicamente con la Opción A primero — un error, un prompt truncado, o algo más sutil como que el modelo solo usara realmente el principio del archivo?
+- El paso de fragmentación del proyecto RAG existe para hacer que cada pieza incrustada sea *específica*. ¿Perder la fragmentación aquí pierde esa especificidad, o darle al modelo el archivo completo en realidad le da *más* con qué trabajar? ¿Bajo qué circunstancias sería correcta cada respuesta?
+
+## Paso 2: Genera preguntas de cuestionario fundamentadas en tus notas
+
+Pídele al modelo un número fijo de preguntas, cada una emparejada con una respuesta esperada — y sé explícito en el prompt de que ambas deben venir del texto específico que le estás entregando, no de conocimiento general sobre el tema:
+
+```python
+import json
+
+GENERATE_PROMPT_TEMPLATE = """You are a study-buddy quiz generator. Read the
+study notes below and write exactly {num_questions} quiz questions that can
+ONLY be answered correctly by someone who has read THESE SPECIFIC notes --
+not generic questions about the general subject. Base every question and
+every expected answer strictly on facts stated in the text.
+
+Reply with ONLY a JSON array, no other text, in this exact shape:
+[
+ {{"question": "...", "expected_answer": "..."}},
+ ...
+]
+
+Study notes:
+{notes_text}
+"""
+
+def generate_questions(notes_text: str, num_questions: int = 5) -> list[dict]:
+ prompt = GENERATE_PROMPT_TEMPLATE.format(num_questions=num_questions, notes_text=notes_text)
+ response = client.chat.completions.create(
+ model=MODEL,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ raw = response.choices[0].message.content.strip()
+ raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
+ return json.loads(raw)
+```
+
+Dos detalles que vale la pena notar:
+
+- **`expected_answer` se genera ahora, pero nunca se muestra al estudiante antes de que responda.** El programa lo mantiene en memoria (en el dict devuelto por `generate_questions`) puramente para que el Paso 3 tenga algo contra qué juzgar después — esta es la misma idea de "fundamentado, no adivinado" que el contexto recuperado del proyecto RAG, solo que usada para *verificar* una respuesta en lugar de *escribir* una.
+- **Pedirle al modelo que responda solo con JSON, y luego parsearlo, es un patrón frágil pero común.** Los modelos ocasionalmente envuelven su respuesta en un fence de código ` ```json ` incluso cuando se les dice que no — las llamadas `removeprefix`/`removesuffix` de arriba lo quitan antes de que corra `json.loads`. Si el parseo aún falla, imprimir la respuesta cruda antes de parsearla es la forma más rápida de ver qué vino realmente.
+
+:::tip[Pide más preguntas de las que necesitas, si la calidad es inconsistente]
+Los modelos pequeños de nivel gratuito ocasionalmente producen una pregunta vaga o extrañamente redactada. Si notas esto en tus propias notas, una solución simple sin código nuevo es pedir unas preguntas extra en el prompt y quedarte solo con las primeras `N` — o solo re-ejecutar la generación, ya que es una sola llamada a la API.
+:::
+
+**✅ Lista de verificación**
+
+
+`generate_questions(notes_text)` devuelve una lista de Python de dicts, cada uno con una clave `"question"` y `"expected_answer"`.
+Leyendo un par de las preguntas generadas, se refieren claramente a detalles específicos de tu archivo de notas, no hechos genéricos sobre el tema que un buscador podría haber escrito.
+Entiendes por qué `expected_answer` se genera pero aún no se imprime en pantalla.
+
+
+**🤔 Pregunta(s) socrática(s)**
+
+- Si le entregaras al modelo un archivo de notas sobre un tema que ya conoce extremadamente bien del entrenamiento (digamos, fotosíntesis básica), ¿cómo sabrías si una pregunta generada está realmente fundamentada en *tus* notas versus el conocimiento previo del modelo? ¿Hay una forma de probarlo?
+- ¿Qué le pasaría a la calidad de las preguntas si `notes_text` estuviera vacío o fuera solo una frase corta? Pruébalo — ¿el modelo produce una respuesta elegante o algo claramente roto?
+
+## Paso 3: Construye el bucle de cuestionario interactivo
+
+Ahora la parte que hace de esto un cuestionario y no solo un generador de preguntas: haz cada pregunta, lee la respuesta escrita del estudiante, y haz que el modelo la juzgue — las respuestas de texto libre no coincidirán palabra por palabra con la respuesta esperada, así que una comparación exacta de cadenas (`==`) marcaría casi todo como incorrecto.
+
+```python
+JUDGE_PROMPT_TEMPLATE = """You are grading a student's quiz answer. Judge
+whether the student's answer is correct, partially correct, or incorrect,
+compared to the expected answer below -- the student won't phrase it
+identically, so judge on meaning, not exact wording.
+
+Question: {question}
+Expected answer: {expected_answer}
+Student's answer: {student_answer}
+
+Reply with ONLY JSON, no other text, in this exact shape:
+{{"verdict": "correct" | "close" | "incorrect", "feedback": "one brief, encouraging sentence"}}
+"""
+
+def judge_answer(question: str, expected_answer: str, student_answer: str) -> dict:
+ prompt = JUDGE_PROMPT_TEMPLATE.format(
+ question=question, expected_answer=expected_answer, student_answer=student_answer
+ )
+ response = client.chat.completions.create(
+ model=MODEL,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ raw = response.choices[0].message.content.strip()
+ raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
+ return json.loads(raw)
+
+
+def run_quiz(questions: list[dict]) -> None:
+ score = 0
+ for i, item in enumerate(questions, start=1):
+ print(f"\nQuestion {i}/{len(questions)}: {item['question']}")
+ student_answer = input("Your answer: ").strip()
+
+ result = judge_answer(item["question"], item["expected_answer"], student_answer)
+ verdict = result.get("verdict", "incorrect")
+ feedback = result.get("feedback", "")
+
+ if verdict == "correct":
+ score += 1
+ print(f"✅ Correct! {feedback}")
+ elif verdict == "close":
+ score += 0.5
+ print(f"🟡 Close. {feedback}")
+ else:
+ print(f"❌ Not quite. {feedback}")
+ print(f" Expected answer: {item['expected_answer']}")
+
+ print(f"\nFinal score: {score}/{len(questions)}")
+```
+
+Un veredicto de tres vías (`correct` / `close` / `incorrect`) es deliberadamente más indulgente que un correcto/incorrecto binario — un estudiante que tiene la idea correcta pero se pierde un detalle recibe crédito parcial y retroalimentación útil, en lugar de un "incorrecto" plano que no dice por qué.
+
+:::tip[input() bloquea hasta que el estudiante presiona Enter]
+`input("Your answer: ")` pausa todo el script en esa línea hasta que escribes algo y presionas Enter — exactamente como `input()` de vuelta en Python 101, solo que ahora dentro de un bucle que también hace llamadas de red antes y después. Si la terminal parece colgarse después de imprimir una pregunta, eso es normal: está esperándote a ti, no a la API.
+:::
+
+**✅ Lista de verificación**
+
+
+`run_quiz(questions)` imprime una pregunta a la vez y realmente espera entrada escrita antes de continuar.
+Una respuesta deliberadamente correcta se marca como correcta, y una deliberadamente incorrecta se marca como incorrecta, con la respuesta esperada mostrada.
+Una respuesta que es aproximadamente correcta pero no exacta en la redacción (p. ej. parafraseada) obtiene un veredicto razonable, no un "incorrecto" injusto.
+
+
+**🤔 Pregunta(s) socrática(s)**
+
+- ¿Por qué juzgar con una *segunda* llamada al LLM por pregunta en lugar de pedirle al modelo que genere la pregunta, la respuesta esperada, *y* un veredicto todo en una sola llamada al momento de generar el cuestionario? ¿Qué saldría mal con ese enfoque, dado que el estudiante aún no ha respondido al momento de la generación?
+- El veredicto `"close"` otorga medio crédito. ¿Cuál es un caso donde la respuesta de un estudiante debería claramente ser "close" en lugar de completamente correcta o completamente incorrecta — y caería tu propia respuesta a una pregunta real de tus notas ahí?
+
+## Paso 4: Lleva el puntaje y ejecútalo de principio a fin
+
+`run_quiz` de arriba ya lleva el `score` mientras avanza e imprime una línea final `score/total` una vez que el bucle termina. Conecta todo junto en un `main()`:
+
+```python
+def main() -> None:
+ notes_text = Path("notes/cell-biology.txt").read_text(encoding="utf-8")
+
+ print("Generating questions...")
+ questions = generate_questions(notes_text)
+ print(f"Got {len(questions)} questions. Let's go!")
+
+ run_quiz(questions)
+
+
+if __name__ == "__main__":
+ main()
+```
+
+Ejecútalo:
+
+```bash
+uv run python study_buddy.py
+```
+
+Deberías ver una breve pausa de "Generating questions..." (una llamada a la API), luego cinco preguntas una a la vez, cada una esperando tu respuesta escrita antes de continuar, terminando con una línea de puntaje final como `Final score: 3.5/5`.
+
+**✅ Lista de verificación**
+
+
+`uv run python study_buddy.py` corre de principio a fin: generación, luego todas las preguntas, luego una línea de puntaje final.
+El número de puntaje final coincide con lo que esperarías de tus propias respuestas (correcto = +1, cerca = +0.5, incorrecto = +0).
+Ejecutarlo de nuevo sobre el mismo archivo de notas produce un conjunto *diferente* de preguntas — confirmando que la generación no está hardcodeada ni cacheada.
+
+
+**🤔 Pregunta(s) socrática(s)**
+
+- Si ejecutaras todo el script dos veces seguidas sobre el mismo archivo de notas, ¿esperarías exactamente las mismas cinco preguntas ambas veces? ¿Por qué sí o por qué no, dado cómo `generate_questions` llama al modelo?
+- Ahora mismo, una mala llamada a `judge_answer` (un fallo de parseo, un error de red) colapsaría todo el cuestionario a mitad de camino, perdiendo el progreso del estudiante en las preguntas restantes. ¿Cuál es un cambio mínimo en `run_quiz` que dejaría que el cuestionario continuara después de un mal juicio en lugar de detenerse por completo?
+
+## ⚠️ Errores comunes
+
+- **Notas delgadas producen preguntas delgadas.** Si tu archivo de notas es solo unos pocos puntos cortos, el modelo tiene muy poco en qué fundamentar cinco preguntas distintas, y obtendrás repetitivas o demasiado fáciles ("¿Cuál es el nombre de...?"). Notas más detalladas, de estilo prosa, producen preguntas notablemente mejores — esto refleja la lección de fragmentación del proyecto RAG: mejor texto de entrada significa mejor resultado, no un prompt más inteligente.
+- **El juez puede ser demasiado estricto o demasiado indulgente.** Un modelo pequeño de nivel gratuito calificando respuestas de texto libre no es un instrumento preciso — puede marcar una respuesta correcta pero extrañamente redactada como incorrecta, o dejar pasar una respuesta que en realidad está perdiendo un detalle clave. Si notas un sesgo consistente, aprieta la redacción de `JUDGE_PROMPT_TEMPLATE` (p. ej. "el crédito parcial solo cuenta si al menos un hecho específico es correcto") en lugar de intentar sortearlo en Python.
+- **Límites de tasa por dos llamadas por pregunta.** A diferencia de una respuesta RAG de un solo disparo, este script hace *dos* llamadas al modelo por pregunta para cuando terminas un cuestionario — una para generación (una vez, por cuestionario) y una para juzgar (una vez, por pregunta). Un cuestionario de 5 preguntas son 6 llamadas en total; ejecuta varios cuestionarios consecutivos en un nivel gratuito y podrías golpear un error de límite de tasa 429. Esto no es un bug — mira el [proyecto de Agente de IA](/docs/projects/ai-agent#manejar-límites-de-tasa) para el mismo patrón y un enfoque de reintento que puedes copiar.
+- **JSON malformado del modelo rompe `json.loads`.** Incluso con una instrucción explícita de "responde solo con JSON", un modelo ocasionalmente añade una frase suelta antes o después del JSON, o deja una coma final. Si golpeas un `JSONDecodeError`, imprime la respuesta cruda antes de parsearla — casi siempre es suficiente para ver exactamente qué salió mal y ajustar el prompt.
+
+## Lo que acabas de construir
+
+Un pipeline pequeño pero completo de "generar, luego interactuar, luego calificar": una llamada al LLM convierte tus propias notas en preguntas fundamentadas con respuestas que solo el programa puede ver, un bucle recoge tus respuestas escritas, y una segunda llamada al LLM juzga cada una por significado en lugar de redacción exacta, con un puntaje acumulado totalizado a lo largo de toda la sesión. Nada aquí fue falsificado en un juguete que no generaliza — apúntalo a un archivo de notas genuinamente útil para otra clase que estés tomando, y es una herramienta de estudio real, no solo un ejercicio de curso.
+
+## A dónde ir desde aquí
+
+- Una vez que un solo archivo de notas deja de ser suficiente — un semestre completo de notas en muchos archivos — reutiliza el pipeline `prepare_notes.py`/`build_index.py`/`retrieve.py` del [proyecto RAG](/docs/projects/rag-notes): recupera los fragmentos más relevantes para un *tema* sobre el que quieras ser examinado, y aliméntalos a `generate_questions` en lugar de un archivo completo.
+- Lleva un registro de las preguntas falladas a través de ejecuciones (escríbelas en un pequeño archivo JSON) y construye un modo "revisa mis puntos débiles" que te vuelva a examinar específicamente sobre los temas que fallaste antes.
+- Añade un ajuste de dificultad a `GENERATE_PROMPT_TEMPLATE` ("preguntas fáciles de recordar" vs. "preguntas que requieren conectar dos ideas de las notas") y compara cuánto más difícil se siente realmente el modo más difícil.
+- Revisita el contenido extra de `try`/`except` de Python 101 — envolver `judge_answer` para que una respuesta malformada no termine todo el cuestionario (ver la pregunta socrática del Paso 4) es exactamente ese patrón.
+
+## Comparte tu proyecto con la clase
+
+¿Construiste algo de lo que estás orgulloso? [`examples/student-projects/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/student-projects) es una galería de proyectos que otros estudiantes han enviado — y su README tiene un tutorial completo y amigable para principiantes para añadir el tuyo vía un **pull request**, incluso si nunca has usado git antes: hacer fork del repositorio, crear una rama, confirmar tus archivos, y abrir el PR, un paso a la vez. No se asume experiencia previa con git.
+
+Bienvenido a escribir Python fuera del navegador. 🎓
+
+
diff --git a/i18n/fr/code.json b/i18n/fr/code.json
index f3954c6..1cdb537 100644
--- a/i18n/fr/code.json
+++ b/i18n/fr/code.json
@@ -925,5 +925,13 @@
"homepage.projects.recipePlannerAgent.summary": {
"message": "Construis un agent IA qui utilise des outils et suggère des repas à partir des ingrédients que tu as sous la main, ancré dans une vraie base de données de recettes locale plutôt que de deviner.",
"description": "Homepage project card summary"
+ },
+ "homepage.projects.studyBuddyAgent.title": {
+ "message": "Construire un Agent de Quiz de Compagnon d'Étude",
+ "description": "Homepage project card title"
+ },
+ "homepage.projects.studyBuddyAgent.summary": {
+ "message": "Transforme tes propres notes d'étude en quiz interactif : un LLM de niveau gratuit écrit des questions ancrées dans tes notes, puis juge tes réponses tapées.",
+ "description": "Homepage project card summary"
}
}
diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx b/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx
index ec3d390..1fe7d49 100644
--- a/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx
+++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx
@@ -161,5 +161,11 @@ Ils sont optionnels et non notés. Parcourez-les à tout moment — l'introducti
summary:
"Construis un agent IA qui utilise des outils avec les deepagents de LangChain, qui suggère des repas à partir des ingrédients que tu as sous la main, ancré dans une vraie base de données de recettes locale.",
},
+ {
+ id: 'study-buddy-agent',
+ title: "Construire un Agent de Quiz de Compagnon d'Étude",
+ summary:
+ "Transforme tes propres notes d'étude en quiz interactif : un LLM de niveau gratuit écrit des questions ancrées dans tes notes, puis juge tes réponses tapées.",
+ },
])}
/>
diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/_category_.json b/i18n/fr/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/_category_.json
new file mode 100644
index 0000000..1663c81
--- /dev/null
+++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Agent de Quiz de Compagnon d'Étude",
+ "position": 15
+}
diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/index.md b/i18n/fr/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/index.md
new file mode 100644
index 0000000..2866b7f
--- /dev/null
+++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/study-buddy-agent/index.md
@@ -0,0 +1,336 @@
+---
+id: study-buddy-agent
+title: "Construire un Agent de Quiz de Compagnon d'Étude"
+sidebar_label: "Agent de Quiz de Compagnon d'Étude"
+slug: /projects/study-buddy-agent
+description: "Passe du playground intégré au navigateur au vrai Python : construis une app de terminal qui transforme tes propres notes d'étude en quiz, en utilisant un LLM de niveau gratuit pour écrire les questions et juger tes réponses."
+---
+
+import ProjectProgressCheckbox from '@site/src/components/ProjectProgressCheckbox';
+import ProjectPublishedDate from '@site/src/components/ProjectPublishedDate';
+import ProjectGreeting from '@site/src/components/ProjectGreeting';
+import {StepChecklist, StepChecklistItem} from '@site/src/components/StepChecklist';
+
+# 🌍 Construire un Agent de Quiz de Compagnon d'Étude
+
+
+
+
+
+Tout dans le cours jusqu'à présent s'est exécuté dans un playground en bac à sable, intégré au navigateur — pour que tu puisses commencer à écrire du Python dès le premier jour avec zéro configuration. Ce projet est l'étape de la graduation : installe le vrai Python sur ta propre machine, puis utilise-le pour construire un outil que tu pourrais réellement continuer à utiliser pour une toute autre classe — une app de quiz qui lit tes propres notes d'étude, écrit des questions ancrées dans ce qui s'y trouve réellement (pas des trivia génériques), t'interroge une question à la fois dans le terminal, et fait juger par un modèle de langage si ta réponse tapée est assez proche, avec un retour bref dans les deux cas.
+
+C'est optionnel et non noté — un bon choix une fois que tu as terminé Python 101 ; rien de Data Analysis n'est requis. Voir [Projets du monde réel](/docs/projects) pour la liste complète et croissante.
+
+## 🎯 Ce que tu vas faire
+
+1. Installer `uv` et obtenir une clé API LLM de niveau gratuit.
+2. Charger un de tes propres fichiers de notes et décider quelle part en donner au modèle comme contexte.
+3. Écrire un prompt qui génère des questions de quiz ancrées dans ce texte spécifique, avec une réponse attendue que le programme garde pour lui.
+4. Construire la boucle interactive : poser une question, prendre ta réponse tapée, faire juger par le modèle et donner un retour.
+5. Suivre un score cumulé et le rapporter à la fin.
+
+## Où exécuter ceci
+
+**En local avec `uv`** est le chemin que suivent les étapes de cette leçon, et le recommandé — c'est du vrai Python tournant sur ta propre machine, le même mouvement « gradue vers du vrai Python » que tout autre projet de cette section.
+
+**GitHub Codespaces** est une alternative sans configuration si tu préfères ne rien installer localement pour l'instant : ouvre [tout le dépôt du cours dans un Codespace gratuit](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python et `uv` sont déjà installés, selon le `.devcontainer/devcontainer.json` du dépôt) et exécute exactement les mêmes commandes `uv` depuis un terminal dans ton onglet de navigateur.
+
+**Google Colab, Kaggle Notebooks, ou Binder** fonctionnent bien aussi — ce projet n'est qu'un script de terminal qui appelle une API hébergée, pas de GPU ni de gros paquet local impliqué. Une version notebook prête à l'emploi vit dans [`examples/study-buddy-agent/notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/study-buddy-agent/notebook.ipynb) — elle reflète la même logique `generate_questions()` / `judge_answer()` / `run_quiz()`, utilise `input()` dans une cellule de la même façon que tu le ferais dans un terminal, et incorpore directement l'un des fichiers de notes d'exemple pour qu'elle s'exécute sans avoir besoin de téléverser un fichier. Lance-la avec l'un des badges ci-dessous :
+
+[](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/study-buddy-agent/notebook.ipynb)
+[](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/study-buddy-agent/notebook.ipynb)
+[](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fstudy-buddy-agent%2Fnotebook.ipynb)
+
+C'est une façon de moindre fidélité de vivre l'expérience qu'un vrai projet local (pas de vraie structure de fichiers, pas de fichiers `.py` séparés), mais c'est une façon raisonnable d'essayer rapidement l'idée.
+
+## Configuration
+
+Tout ce dont tu as besoin avant l'étape 1 — installer `uv`, créer le projet, et obtenir une clé API — se trouve ici, tout à l'avance, pour que les étapes ci-dessous puissent se concentrer purement sur la logique du quiz.
+
+### Installer `uv`
+
+`uv` est un seul outil qui remplace la chaîne habituelle « installe Python, puis installe pip, puis installe un outil d'environnement virtuel, puis installe les paquets » — il peut installer et gérer les versions de Python lui-même, en plus des dépendances de ton projet.
+
+**macOS / Linux** (terminal) :
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+**Windows** (PowerShell) :
+
+```powershell
+powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
+```
+
+Ferme et rouvre ton terminal, puis confirme que c'est installé :
+
+```bash
+uv --version
+```
+
+### Créer le projet
+
+```bash
+uv init study-buddy-agent
+cd study-buddy-agent
+uv add openai python-dotenv
+```
+
+`uv init` crée un petit projet (un `pyproject.toml` qui suit tes dépendances) et `uv add` installe les paquets dans un environnement isolé pour ce projet — sans configuration manuelle d'environnement virtuel. `openai` est la bibliothèque cliente que cette leçon utilise (GitHub Models, le fournisseur par défaut suggéré ci-dessous, expose une API compatible OpenAI) ; `python-dotenv` te permet de garder ta clé API dans un fichier `.env` local plutôt que de la `export`-er à chaque session.
+
+### Obtenir une clé API IA gratuite
+
+**Choisis le fournisseur que tu préfères** — aucun n'exige de carte de crédit au moment où j'écris ceci, et ce cours n'en favorise aucun. Le script d'exemple dans le dépôt du cours ([`examples/study-buddy-agent/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/study-buddy-agent)) utilise GitHub Models par défaut ; passer à un autre fournisseur est un petit changement bien documenté.
+
+| Fournisseur | Où obtenir une clé | Pourquoi tu pourrais le choisir |
+|---|---|---|
+| **GitHub Models** *(défaut suggéré)* | [github.com/settings/tokens](https://github.com/settings/tokens) — un jeton d'accès personnel avec la portée `models: read` | Pas d'inscription séparée — tu as déjà un compte GitHub. Des limites de niveau gratuit plus généreuses que celles de Gemini. |
+| Gemini | [Google AI Studio](https://aistudio.google.com/) | L'option la plus couramment référencée. |
+| Groq | [console.groq.com/keys](https://console.groq.com/keys) | Inférence rapide, niveau gratuit généreux, pas de carte. |
+| Mistral | [console.mistral.ai/api-keys](https://console.mistral.ai/api-keys) | L'un des quotas gratuits permanents les plus généreux. |
+| Cerebras | [cloud.cerebras.ai](https://cloud.cerebras.ai/) | Volume quotidien de tokens élevé, pas de carte. |
+| OpenRouter | [openrouter.ai/keys](https://openrouter.ai/keys) | Une API, de nombreux modèles gratuits — idéal pour comparer les fournisseurs. |
+
+Quel que soit ton choix, le processus est le même :
+
+1. Connecte-toi et génère une clé API sur le site de ce fournisseur.
+2. **Ne colle jamais cette clé directement dans le code et ne la commite jamais dans un dépôt.** Mets-la dans un fichier `.env` à la place :
+
+```bash
+# .env
+GITHUB_TOKEN=your-key-here
+```
+
+`python-dotenv` lit ce fichier vers `os.environ` automatiquement, le même pattern utilisé tout au long des projets [Agent IA](/docs/projects/ai-agent) et [Appli RAG](/docs/projects/rag-notes) si tu as fait l'un ou l'autre. Une clé API est un secret, exactement comme un mot de passe — quiconque la possède peut utiliser le quota de ton compte.
+
+:::tip[Un fichier `.env` est souvent plus pratique que export]
+Au lieu de faire `export` d'une clé dans chaque nouvelle session de terminal, mets-la dans un fichier `.env` dans ton dossier de projet (voir le `.env.example` de l'exemple du dépôt) et charge-la avec `load_dotenv()`, appelée une fois près du haut de ton script.
+:::
+
+Avec `uv`, `openai`, `python-dotenv`, et une clé dans `.env`, la configuration est terminée — tout à partir d'ici est de la logique de quiz.
+
+## Étape 1 : Charge tes notes et choisis une stratégie de contexte
+
+Mets un fichier `.txt` ou `.md` de tes propres notes d'étude quelque part dans ton projet — un dossier `notes/`, même convention que le [projet RAG](/docs/projects/rag-notes), est un endroit raisonnable. Le lire n'a rien de nouveau :
+
+```python
+from pathlib import Path
+
+notes_text = Path("notes/cell-biology.txt").read_text(encoding="utf-8")
+```
+
+Voici la décision de conception que ce projet te demande de prendre explicitement, plutôt que de passer outre : **quelle part de tes notes le modèle devrait-il réellement voir ?**
+
+- **Option A — donne le fichier entier comme contexte.** L'approche la plus simple possible : lis un fichier, remets son texte entier au modèle dans le prompt, terminé. Cela fonctionne très bien tant qu'un seul fichier tient confortablement dans la fenêtre de contexte du modèle — quelques milliers de mots ne posent aucun problème pour n'importe quel modèle gratuit moderne.
+- **Option B — découper, embedder, et récupérer**, exactement comme le fait le [projet RAG](/docs/projects/rag-notes) : divise tes notes en petits morceaux, embedde-les localement, et ne récupère que les plus pertinents pour chaque question. Cela passe à l'échelle pour un dossier de notes avec des dizaines de fichiers longs qui ne tiendraient jamais dans un seul prompt.
+
+**Cette leçon choisit l'Option A** et est explicite sur le compromis : c'est moins évolutif, mais c'est une leçon entière plus simple à écrire, lire et déboguer — pas de modèle d'embedding, pas de recherche vectorielle, pas d'étape séparée de construction d'index, juste une chaîne. Ce compromis mérite d'être nommé à voix haute, le même principe d'ancrage que le projet RAG de toute façon : une bonne question de quiz doit venir de texte que le modèle a réellement reçu, pas de texte dont il devine qu'il pourrait être pertinent à partir des données d'entraînement. Si tes propres notes dépassent un seul fichier, ne réinvente pas la récupération — réutilise `retrieve.py` de l'exemple du projet RAG et remplace le prompt de l'étape 2 pour utiliser des morceaux récupérés au lieu d'un fichier entier.
+
+**✅ Liste de vérification**
+
+
+Tu as un dossier `notes/` avec au moins un vrai fichier `.txt`/`.md` de tes propres notes d'étude dedans.
+Lire le fichier et imprimer sa longueur montre un vrai nombre de caractères, pas `0` ou une erreur.
+Tu peux expliquer, en une phrase, pourquoi cette leçon donne le fichier entier au modèle au lieu de récupérer des morceaux.
+
+
+**🤔 Question(s) socratique(s)**
+
+- Si ton fichier de notes faisait 50 pages au lieu d'une page, qu'est-ce qui tournerait mal précisément avec l'Option A d'abord — une erreur, un prompt tronqué, ou quelque chose de plus subtil comme le modèle n'utilisant réellement que le début du fichier ?
+- L'étape de découpage du projet RAG existe pour rendre chaque morceau embeddé *spécifique*. Sauter le découpage ici perd-il cette spécificité, ou donner le fichier entier au modèle lui donne-t-il réellement *plus* avec quoi travailler ? Dans quelles circonstances chaque réponse serait-elle juste ?
+
+## Étape 2 : Génère des questions de quiz ancrées dans tes notes
+
+Demande au modèle un nombre fixe de questions, chacune appariée à une réponse attendue — et sois explicite dans le prompt que les deux doivent venir du texte spécifique que tu lui donnes, pas de la connaissance générale sur le sujet :
+
+```python
+import json
+
+GENERATE_PROMPT_TEMPLATE = """You are a study-buddy quiz generator. Read the
+study notes below and write exactly {num_questions} quiz questions that can
+ONLY be answered correctly by someone who has read THESE SPECIFIC notes --
+not generic questions about the general subject. Base every question and
+every expected answer strictly on facts stated in the text.
+
+Reply with ONLY a JSON array, no other text, in this exact shape:
+[
+ {{"question": "...", "expected_answer": "..."}},
+ ...
+]
+
+Study notes:
+{notes_text}
+"""
+
+def generate_questions(notes_text: str, num_questions: int = 5) -> list[dict]:
+ prompt = GENERATE_PROMPT_TEMPLATE.format(num_questions=num_questions, notes_text=notes_text)
+ response = client.chat.completions.create(
+ model=MODEL,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ raw = response.choices[0].message.content.strip()
+ raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
+ return json.loads(raw)
+```
+
+Deux détails qui méritent l'attention :
+
+- **`expected_answer` est générée maintenant, mais jamais montrée à l'élève avant qu'il ne réponde.** Le programme la garde en mémoire (dans le dict retourné par `generate_questions`) uniquement pour que l'étape 3 ait quelque chose contre quoi juger plus tard — c'est la même idée « ancré, pas deviné » que le contexte récupéré du projet RAG, juste utilisée pour *vérifier* une réponse au lieu d'en *écrire* une.
+- **Demander au modèle de répondre uniquement avec du JSON, puis le parser, est un pattern fragile mais courant.** Les modèles enveloppent parfois leur réponse dans une clôture de code ` ```json ` même quand on leur a dit de ne pas le faire — les appels `removeprefix`/`removesuffix` ci-dessus l'enlèvent avant que `json.loads` s'exécute. Si le parsing échoue encore, imprimer la réponse brute avant de la parser est le moyen le plus rapide de voir ce qui est réellement revenu.
+
+:::tip[Demande plus de questions que nécessaire, si la qualité est inconstante]
+Les petits modèles de niveau gratuit produisent parfois une question vague ou bizarrement formulée. Si tu remarques cela sur tes propres notes, une solution simple sans nouveau code est de demander quelques questions supplémentaires dans le prompt et de ne garder que les premières `N` — ou juste de relancer la génération, puisque c'est un seul appel API.
+:::
+
+**✅ Liste de vérification**
+
+
+`generate_questions(notes_text)` retourne une liste Python de dicts, chacun avec une clé `"question"` et `"expected_answer"`.
+En lisant quelques-unes des questions générées, elles se réfèrent clairement à des détails spécifiques de ton fichier de notes, pas des faits génériques sur le sujet qu'un moteur de recherche aurait pu écrire.
+Tu comprends pourquoi `expected_answer` est générée mais pas encore affichée à l'écran.
+
+
+**🤔 Question(s) socratique(s)**
+
+- Si tu donnais au modèle un fichier de notes sur un sujet qu'il connaît déjà extrêmement bien grâce à l'entraînement (disons, la photosynthèse de base), comment saurais-tu si une question générée est réellement ancrée dans *tes* notes plutôt que dans la connaissance préalable du modèle ? Y a-t-il un moyen de le tester ?
+- Qu'arriverait-il à la qualité des questions si `notes_text` était vide ou juste une phrase courte ? Essaie — le modèle produit-il une réponse élégante ou quelque chose de manifestement cassé ?
+
+## Étape 3 : Construis la boucle de quiz interactive
+
+Maintenant la partie qui fait de ceci un quiz et pas juste un générateur de questions : pose chaque question, lis la réponse tapée de l'élève, et fais juger par le modèle — les réponses en texte libre ne correspondront pas mot pour mot à la réponse attendue, donc une comparaison exacte de chaînes (`==`) marquerait presque tout comme incorrect.
+
+```python
+JUDGE_PROMPT_TEMPLATE = """You are grading a student's quiz answer. Judge
+whether the student's answer is correct, partially correct, or incorrect,
+compared to the expected answer below -- the student won't phrase it
+identically, so judge on meaning, not exact wording.
+
+Question: {question}
+Expected answer: {expected_answer}
+Student's answer: {student_answer}
+
+Reply with ONLY JSON, no other text, in this exact shape:
+{{"verdict": "correct" | "close" | "incorrect", "feedback": "one brief, encouraging sentence"}}
+"""
+
+def judge_answer(question: str, expected_answer: str, student_answer: str) -> dict:
+ prompt = JUDGE_PROMPT_TEMPLATE.format(
+ question=question, expected_answer=expected_answer, student_answer=student_answer
+ )
+ response = client.chat.completions.create(
+ model=MODEL,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ raw = response.choices[0].message.content.strip()
+ raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
+ return json.loads(raw)
+
+
+def run_quiz(questions: list[dict]) -> None:
+ score = 0
+ for i, item in enumerate(questions, start=1):
+ print(f"\nQuestion {i}/{len(questions)}: {item['question']}")
+ student_answer = input("Your answer: ").strip()
+
+ result = judge_answer(item["question"], item["expected_answer"], student_answer)
+ verdict = result.get("verdict", "incorrect")
+ feedback = result.get("feedback", "")
+
+ if verdict == "correct":
+ score += 1
+ print(f"✅ Correct! {feedback}")
+ elif verdict == "close":
+ score += 0.5
+ print(f"🟡 Close. {feedback}")
+ else:
+ print(f"❌ Not quite. {feedback}")
+ print(f" Expected answer: {item['expected_answer']}")
+
+ print(f"\nFinal score: {score}/{len(questions)}")
+```
+
+Un verdict à trois voies (`correct` / `close` / `incorrect`) est délibérément plus indulgent qu'un bon/mauvais binaire — un élève qui a la bonne idée mais rate un détail reçoit un crédit partiel et un retour utile, plutôt qu'un « incorrect » plat qui ne dit pas pourquoi.
+
+:::tip[input() bloque jusqu'à ce que l'élève appuie sur Entrée]
+`input("Your answer: ")` met en pause tout le script à cette ligne jusqu'à ce que tu tapes quelque chose et appuies sur Entrée — exactement comme `input()` de retour dans Python 101, juste maintenant assis dans une boucle qui fait aussi des appels réseau avant et après. Si le terminal semble se bloquer après qu'une question soit affichée, c'est normal : il t'attend, pas l'API.
+:::
+
+**✅ Liste de vérification**
+
+
+`run_quiz(questions)` affiche une question à la fois et attend réellement une saisie tapée avant de continuer.
+Une réponse délibérément correcte est marquée correcte, et une délibérément incorrecte est marquée incorrecte, avec la réponse attendue affichée.
+Une réponse à peu près juste mais pas exacte dans le libellé (ex. paraphrasée) obtient un verdict raisonnable, pas un « incorrect » injuste.
+
+
+**🤔 Question(s) socratique(s)**
+
+- Pourquoi juger avec un *deuxième* appel LLM par question plutôt que de demander au modèle de générer la question, la réponse attendue, *et* un verdict en un seul appel au moment de la génération du quiz ? Qu'est-ce que cette approche se tromperait, étant donné que l'élève n'a pas encore répondu au moment de la génération ?
+- Le verdict `"close"` accorde un demi-crédit. Quel est un cas où la réponse d'un élève devrait clairement être « close » plutôt que complètement correcte ou complètement incorrecte — et ta propre réponse à une vraie question de tes notes y tomberait-elle ?
+
+## Étape 4 : Suis le score et exécute-le de bout en bout
+
+`run_quiz` ci-dessus suit déjà `score` au fur et à mesure et imprime une ligne finale `score/total` une fois la boucle terminée. Relie tout ensemble dans un `main()` :
+
+```python
+def main() -> None:
+ notes_text = Path("notes/cell-biology.txt").read_text(encoding="utf-8")
+
+ print("Generating questions...")
+ questions = generate_questions(notes_text)
+ print(f"Got {len(questions)} questions. Let's go!")
+
+ run_quiz(questions)
+
+
+if __name__ == "__main__":
+ main()
+```
+
+Exécute-le :
+
+```bash
+uv run python study_buddy.py
+```
+
+Tu devrais voir une brève pause « Generating questions... » (un appel API), puis cinq questions une à la fois, chacune attendant ta réponse tapée avant de continuer, se terminant par une ligne de score final comme `Final score: 3.5/5`.
+
+**✅ Liste de vérification**
+
+
+`uv run python study_buddy.py` s'exécute de bout en bout : génération, puis toutes les questions, puis une ligne de score final.
+Le nombre de score final correspond à ce que tu attendrais de tes propres réponses (correct = +1, proche = +0,5, incorrect = +0).
+Le relancer sur le même fichier de notes produit un ensemble *différent* de questions — confirmant que la génération n'est ni codée en dur ni mise en cache.
+
+
+**🤔 Question(s) socratique(s)**
+
+- Si tu exécutais tout le script deux fois de suite sur le même fichier de notes, t'attendrais-tu exactement aux mêmes cinq questions les deux fois ? Pourquoi ou pourquoi pas, étant donné comment `generate_questions` appelle le modèle ?
+- Actuellement, un mauvais appel `judge_answer` (un échec de parsing, une erreur réseau) ferait planter tout le quiz à mi-chemin, perdant la progression de l'élève sur les questions restantes. Quel est un changement minimal à `run_quiz` qui laisserait le quiz continuer après un mauvais jugement au lieu de s'arrêter complètement ?
+
+## ⚠️ Pièges courants
+
+- **Des notes maigres produisent des questions maigres.** Si ton fichier de notes n'est que quelques courts points, le modèle a très peu sur quoi ancrer cinq questions distinctes, et tu obtiendras des questions répétitives ou trop faciles (« Quel est le nom de... ? »). Des notes plus détaillées, de style prose, produisent des questions nettement meilleures — cela reflète la leçon de découpage du projet RAG : un meilleur texte d'entrée signifie un meilleur résultat, pas un prompt plus malin.
+- **Le juge peut être trop strict ou trop indulgent.** Un petit modèle de niveau gratuit notant des réponses en texte libre n'est pas un instrument précis — il peut marquer une réponse correcte mais bizarrement formulée comme fausse, ou laisser passer une réponse qui manque en réalité un détail clé. Si tu remarques un biais constant, resserre le libellé de `JUDGE_PROMPT_TEMPLATE` (ex. « le crédit partiel ne compte que si au moins un fait spécifique est correct ») plutôt que d'essayer de le contourner en Python.
+- **Limites de débit de deux appels par question.** Contrairement à une réponse RAG en un seul coup, ce script fait *deux* appels de modèle par question à la fin d'un quiz — un pour la génération (une fois, par quiz) et un pour le jugement (une fois, par question). Un quiz de 5 questions, c'est 6 appels au total ; exécute plusieurs quiz à la suite sur un niveau gratuit et tu peux heurter une erreur de limite de débit 429. Ce n'est pas un bug — voir le [projet Agent IA](/docs/projects/ai-agent#gérer-les-limites-de-débit) pour le même pattern et une approche de nouvelle tentative que tu peux copier.
+- **Un JSON malformé du modèle casse `json.loads`.** Même avec une instruction explicite « réponds uniquement avec du JSON », un modèle ajoute parfois une phrase parasite avant ou après le JSON, ou laisse une virgule finale. Si tu heurtes un `JSONDecodeError`, imprime la réponse brute avant de la parser — c'est presque toujours suffisant pour voir exactement ce qui n'a pas marché et ajuster le prompt.
+
+## Ce que tu viens de construire
+
+Un petit pipeline mais complet « générer, puis interagir, puis noter » : un appel LLM transforme tes propres notes en questions ancrées avec des réponses que seul le programme peut voir, une boucle collecte tes réponses tapées, et un second appel LLM juge chacune sur le sens plutôt que sur le libellé exact, avec un score cumulé totalisé sur toute la session. Rien ici n'a été truqué en un jouet qui ne généralise pas — pointe-le vers un fichier de notes réellement utile pour une autre classe que tu suis, et c'est un vrai outil d'étude, pas juste un exercice de cours.
+
+## Où aller à partir d'ici
+
+- Une fois qu'un seul fichier de notes ne suffit plus — un semestre complet de notes réparties sur de nombreux fichiers — réutilise le pipeline `prepare_notes.py`/`build_index.py`/`retrieve.py` du [projet RAG](/docs/projects/rag-notes) : récupère les morceaux les plus pertinents pour un *sujet* sur lequel tu veux être interrogé, et nourris-en `generate_questions` au lieu d'un fichier entier.
+- Suis les questions manquées à travers les exécutions (écris-les dans un petit fichier JSON) et construis un mode « révise mes points faibles » qui te re-questionne spécifiquement sur les sujets que tu as ratés auparavant.
+- Ajoute un réglage de difficulté à `GENERATE_PROMPT_TEMPLATE` (« questions de rappel faciles » vs « questions exigeant de relier deux idées des notes ») et compare combien le mode plus difficile se ressent réellement plus dur.
+- Revisite le contenu bonus `try`/`except` de Python 101 — envelopper `judge_answer` pour qu'une réponse malformée ne termine pas tout le quiz (voir la question socratique de l'étape 4) est exactement ce pattern.
+
+## Partage ton projet avec la classe
+
+Tu as construit quelque chose dont tu es fier ? [`examples/student-projects/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/student-projects) est une galerie de projets soumis par d'autres élèves — et son README a un tutoriel complet et adapté aux débutants pour ajouter le tien via une **pull request**, même si tu n'as jamais utilisé git avant : forker le dépôt, créer une branche, commiter tes fichiers, et ouvrir la PR, une étape à la fois. Aucune expérience préalable avec git n'est supposée.
+
+Bienvenue dans l'écriture de Python hors du navigateur. 🎓
+
+