From 7b0c1e2c16eac23098f82fa38222a937055f20ea Mon Sep 17 00:00:00 2001 From: Abderrahim Adrabi <184391033+abderrahim-lectures@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:20:33 +0100 Subject: [PATCH] i18n: translate Discord Trivia Bot project into ar/es/fr Co-Authored-By: Claude Sonnet 5 --- i18n/ar/code.json | 8 + .../current/projects/index.mdx | 4 + .../projects/trivia-bot/_category_.json | 4 + .../current/projects/trivia-bot/index.md | 540 ++++++++++++++++++ .../projects/webcam-object-counter/index.md | 302 ++++++++++ .../current/projects/wordle-clone/index.md | 332 +++++++++++ i18n/es/code.json | 8 + .../current/projects/index.mdx | 4 + .../projects/trivia-bot/_category_.json | 4 + .../current/projects/trivia-bot/index.md | 540 ++++++++++++++++++ .../projects/webcam-object-counter/index.md | 302 ++++++++++ .../current/projects/wordle-clone/index.md | 332 +++++++++++ i18n/fr/code.json | 8 + .../current/projects/index.mdx | 4 + .../projects/trivia-bot/_category_.json | 4 + .../current/projects/trivia-bot/index.md | 540 ++++++++++++++++++ .../projects/webcam-object-counter/index.md | 302 ++++++++++ .../current/projects/wordle-clone/index.md | 332 +++++++++++ 18 files changed, 3570 insertions(+) create mode 100644 i18n/ar/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json create mode 100644 i18n/ar/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md create mode 100644 i18n/ar/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md create mode 100644 i18n/ar/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md create mode 100644 i18n/es/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json create mode 100644 i18n/es/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md create mode 100644 i18n/es/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md create mode 100644 i18n/es/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md create mode 100644 i18n/fr/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json create mode 100644 i18n/fr/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md create mode 100644 i18n/fr/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md create mode 100644 i18n/fr/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md diff --git a/i18n/ar/code.json b/i18n/ar/code.json index 8da8a88..a05eec5 100644 --- a/i18n/ar/code.json +++ b/i18n/ar/code.json @@ -933,5 +933,13 @@ "homepage.projects.studyBuddyAgent.summary": { "message": "حوّل ملاحظات دراستك الخاصة إلى اختبار تفاعلي: نموذج لغوي من مستوى مجاني يكتب أسئلة مبنية على ملاحظاتك، ثم يحكم على إجاباتك المكتوبة.", "description": "Homepage project card summary" + }, + "homepage.projects.triviaBot.title": { + "message": "بناء بوت Trivia على Discord", + "description": "Homepage project card title" + }, + "homepage.projects.triviaBot.summary": { + "message": "شغّل جولات Trivia في خادم Discord بـ`discord.py`: لوحة متصدّرين دائمة، وأسئلة تُولَّد حديثًا حول أي موضوع باستخدام LLM من مستوى مجاني.", + "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 e13042d..b80cd36 100644 --- a/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -166,6 +166,10 @@ import {mergeProjectMeta} from '@site/src/data/projects'; title: 'بناء وكيل اختبارات رفيق المذاكرة', summary: 'حوّل ملاحظات دراستك الخاصة إلى اختبار تفاعلي: نموذج لغوي من مستوى مجاني يكتب أسئلة مبنية على ملاحظاتك، ثم يحكم على إجاباتك المكتوبة.', + id: 'trivia-bot', + title: 'بناء بوت Trivia على Discord', + summary: + 'ابنِ بوتًا بـ`discord.py` يشغّل جولات Trivia في خادم، ويتتبّع النقاط على لوحة متصدّرين دائمة، ويمكنه توليد أسئلة جديدة حول أي موضوع باستخدام LLM مجاني.', }, ])} /> diff --git a/i18n/ar/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json b/i18n/ar/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json new file mode 100644 index 0000000..40ab67a --- /dev/null +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "بوت Trivia على Discord", + "position": 12 +} diff --git a/i18n/ar/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md b/i18n/ar/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md new file mode 100644 index 0000000..11c135e --- /dev/null +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md @@ -0,0 +1,540 @@ +--- +id: trivia-bot +title: "بناء بوت Trivia على Discord" +sidebar_label: "بناء بوت Trivia على Discord" +slug: /projects/trivia-bot +description: "ابنِ بوتًا بـ`discord.py` يشغّل جولات Trivia في خادم، ويتتبّع النقاط على لوحة متصدّرين دائمة، ويمكنه توليد أسئلة جديدة حول أي موضوع باستخدام LLM مجاني." +--- + +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'; + +# 🌍 بناء بوت Trivia على Discord + + + + + +بوت `discord.py` حيّ يشغّل جولات Trivia في خادم: انشر سؤالًا، واجمع الإجابات ضمن مهلة زمنية، واكشف من أجاب إجابة صحيحة، واحتفظ بلوحة متصدّرين دائمة عبر الجولات. تتوقف معظم بوتات Trivia عند بنك أسئلة ثابت — يضيف هذا البوت لمسة تناسب دورة Python: يمكنه أيضًا توليد سؤال جديد حول أي موضوع في الحال باستخدام LLM مجاني، بدلًا من الاكتفاء دائمًا بالسؤال من قائمة جاهزة. + +يفترض هذا Python بمستوى 101. لا يتطلب أي مشروع واقعي آخر قبله، رغم أنه إذا كنت قد بنيت بالفعل [ابنِ تطبيق RAG](/docs/projects/rag-notes)، فسيبدو إعداد LLM المجاني أدناه مألوفًا. + +هذا اختياري وغير مُقيَّم. راجع [مشاريع من العالم الحقيقي](/docs/projects) للاطلاع على القائمة الكاملة والنامية. + +## 🎯 ما ستفعله + +1. أنشئ تطبيق بوت Discord واحصل على رمزه المميز من بوابة المطوّرين المجانية في Discord. +2. ثبّت `uv`، وأعِدَّ مشروعًا، وأضف `discord.py` إلى جانب عميل LLM مجاني. +3. ابنِ بنك أسئلة Trivia ثابتًا وأمرًا أساسيًا بشرطة مائلة في Discord ينشر واحدًا. +4. أضف لوحة متصدّرين دائمة لكل لاعب، محفوظة عبر عمليات إعادة التشغيل. +5. أضف وضع توليد الأسئلة بالـLLM: أعطِ البوت موضوعًا، واحصل على سؤال جديد. +6. اربط كل ذلك في حلقة جولة كاملة — انشر سؤالًا، واجمع الإجابات ضمن مهلة زمنية، واكشف الإجابة، وحدّث لوحة المتصدّرين. +7. ادعُ البوت إلى خادم اختبار وشغّل جولات حقيقية، من البداية إلى النهاية. + +## أين تُشغّل هذا + +**محليًا باستخدام `uv`** هو الخيار العملي الوحيد فعليًا هنا، أكثر من معظم المشاريع الأخرى في هذه السلسلة. بوت Discord ليس سكربتًا يعمل مرة واحدة وينتهي — إنه يمسك اتصالًا مفتوحًا بـDiscord ويحتاج إلى الاستمرار في العمل طالما أردته أن يستجيب لـ`/trivia` ويجمع الإجابات، ما يعني عملية حقيقية محلية (أو مستضافة) طويلة التشغيل، لا أمرًا لمرة واحدة. + +**GitHub Codespaces** يعمل أيضًا، وهو بديل معقول إذا فضّلت عدم تثبيت أي شيء محليًا: افتح [مستودع الدورة كاملًا في Codespace مجاني](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node وPython و`uv` مثبّتة بالفعل، وفق `.devcontainer/devcontainer.json` الخاص بالمستودع) وشغّل `uv run python bot.py` في طرفية هناك — يبقى يعمل ما دامت تلك الطرفية (والـCodespace) مفتوحة، نفس شرط "العملية طويلة التشغيل" كما في تشغيله محليًا. + +**Google Colab وKaggle Notebooks ملاءمة ضعيفة للبوت الفعلي** — كن صادقًا مع نفسك بشأن ذلك بدلًا من مقاومته. بُنيت دفاتر الملاحظات حول تشغيل خلية، والحصول على مخرجات، والانتقال إلى الخلية التالية؛ إنها ليست مقصودة لعملية خلفية تجلس وتنتظر الأحداث إلى أجل غير مسمّى. يمكنك *أن* تبدأ حلقة أحداث البوت في خلية دفتر ملاحظات، لكن اللحظة التي يعيد فيها وقت تشغيل الدفتر تدوير نفسه، أو ينقطع اتصاله، أو تُغلق التبويب، يسقط البوت معه — تجاوز Colab/Kaggle للبوت الحيّ واستخدم عملية محلية حقيقية أو Codespaces بدلًا من ذلك. + +مع ذلك، فإن توليد الأسئلة والتسجيل *أسفل* البوت هما مجرد دوال عادية تشغّل خلية واحدة في كل مرة، وهو بالضبط ما تجيده دفاتر الملاحظات. تفتح الشارات أدناه دفتر ملاحظات يولّد أسئلة LLM حقيقية حول بضعة مواضيع نموذجية ويشغّل بضعة "لاعبين" وهميين عبر منطق التسجيل، لترى كليهما يعمل دون تثبيت أي شيء محليًا. إنه يتوقف عمدًا قبل طبقة Discord — من أجل ذلك، عُد إلى هنا وشغّل `bot.py` محليًا أو في Codespaces كما هو موصوف أعلاه. + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/trivia-bot/notebook.ipynb) +[![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/trivia-bot/notebook.ipynb) + +## الإعداد + +كل شيء في هذا القسم يحتاج فقط إلى الحدوث مرة واحدة، قبل أن تكتب أي سطر من البوت نفسه: تثبيت `uv`، وإنشاء تطبيق بوت Discord والحصول على رمزه المميز، والحصول على مفتاح LLM مجاني، وإعداد المشروع. تفترض كل خطوة بعد هذه أن كل ذلك قد أُنجز بالفعل. + +### ثبّت `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 +``` + +### أنشئ تطبيق بوت Discord واحصل على رمز مميز + +[بوابة المطوّرين](https://discord.com/developers/applications) في Discord مجانية ولا تتطلب بطاقة: + +1. سجّل الدخول وانقر على **New Application**، أعطِه اسمًا (مثل "trivia-bot")، وأنشئه. +2. افتح تبويب **Bot** على اليسار. يضيف Discord مستخدم بوت إلى تطبيقك تلقائيًا. +3. انقر على **Reset Token** (أو **View Token** إذا كانت هذه أول مرة) وانسخه. هذا الرمز تمامًا مثل كلمة مرور — أي شخص يملكه يستطيع التحكم في بوتك — فعامله بنفس الطريقة التي تعامل بها مفتاح API للـLLM: لا تلصقه أبدًا في الكود، ولا تثبّته أبدًا. +4. في نفس تبويب **Bot**، مرّر إلى **Privileged Gateway Intents** وشغّل **Message Content**. هذا مطلوب لكي يقرأ البوت فعلًا الحرف الذي يرد به اللاعب — بدونه، يستقبل `discord.py` سلسلة فارغة لمحتوى كل رسالة مهما كان الكود الذي تكتبه. +5. افتح **OAuth2 → URL Generator**. تحت **Scopes**، حدّد كلاً من `bot` و`applications.commands` (أوامر الشرطة المائلة تحتاج الثاني تحديدًا)؛ تحت **Bot Permissions**، حدّد على الأقل **Send Messages** و**Read Message History**. أبقِ الرابط المُولَّد في متناول يدك — ستستخدمه في الخطوة الأخيرة لتدعو البوت فعلًا إلى خادم. + +:::tip[رمز البوت سرّ، تمامًا مثل مفتاح API] +لا ترمّز رمز البوت في الكود مطلقًا، ولا تثبّته مطلقًا، واحتفظ به في ملف `.env` محلي (أدناه) بدلًا من ذلك — الرمز المسرَّب للبوت يتيح لأي شخص انتحال شخصية بوتك في كل خادم يوجد فيه، تمامًا كما يتيح مفتاح LLM المسرَّب لأي شخص إنفاق حصتك. +::: + +### احصل على مفتاح LLM مجاني + +يحتاج وضع توليد الأسئلة إلى مفتاح LLM مجاني — **اختر أي مزوّد يعجبك**، لا يتطلب أي منها بطاقة ائتمان وقت كتابة هذا: + +| المزوّد | أين تحصل على مفتاح | لماذا قد تختاره | +|---|---|---| +| **GitHub Models** *(الافتراضي المُقترَح)* | [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) | واجهة API واحدة، نماذج مجانية كثيرة — جيد لمقارنة المزوّدين. | + +بنك الأسئلة الثابت (الخطوة 1) لا يحتاج إلى مفتاح LLM إطلاقًا — تحتاج واحدًا فقط عند وصولك إلى توليد الأسئلة حسب الموضوع في الخطوة 3. + +### أعِدَّ المشروع + +```bash +uv init trivia-bot +cd trivia-bot +uv add discord.py openai python-dotenv +``` + +`discord.py` هي المكتبة التي تتحدث إلى Discord — الاتصال ببوابته، وتسجيل أوامر الشرطة المائلة، واستقبال/إرسال الرسائل. يتحدث `openai` إلى نقطة نهاية GitHub Models المتوافقة مع OpenAI للمزوّد الافتراضي أعلاه؛ استبدله بحزمة مزوّدك الخاصة إذا اخترت مزوّدًا مختلفًا. تحمّل `python-dotenv` الأسرار من ملف `.env` محلي. + +أنشئ ملف `.env` في مجلد المشروع (لا تثبّته أبدًا) مع **كلا** السرّين من هذا القسم: + +```bash +# .env +DISCORD_BOT_TOKEN=your-bot-token-here +GITHUB_TOKEN=your-llm-key-here +``` + +**✅ قائمة التحقق** + + +يوجد تطبيق Discord وبوت في بوابة المطوّرين، ونسخت رمزه المميز. +"Message Content" مفعّل تحت Privileged Gateway Intents. +لديك مفتاح LLM مجاني من مزوّد من اختيارك. +اكتمل `uv init`/`uv add` دون أخطاء، ويحتوي `.env` على كل من `DISCORD_BOT_TOKEN` ومفتاح LLM الخاص بك مضبوطين. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +- لماذا يتطلب Discord منك تفعيل "Message Content" صراحةً كنية *مميزة*، بدلًا من منح كل بوت وصولًا إلى نص الرسائل افتراضيًا؟ +- رمز البوت ومفتاح API للـLLM كلاهما سرّان، لكنهما يُصادِقان على خدمتين مختلفتين تمامًا. ما الذي قد يسوء لو بدّلت بالخطأ أي متغيّر بيئة يحمل أي قيمة؟ + +## الخطوة 1: بنك أسئلة ثابت وأمر أساسي بشرطة مائلة + +ابدأ بأبسط مصدر أسئلة ممكن — قائمة Python عادية من القواميس — وتوصيل Discord كافٍ لنشر واحد: + +```python +# questions.py +"""A small fixed bank of trivia questions. Every question, from this bank +or later generated by an LLM, is the same shape: +{"question": str, "options": list[str], "answer_index": int}.""" + +import random + +QUESTION_BANK = [ + { + "question": "What year was Python first released?", + "options": ["1989", "1991", "1995", "2000"], + "answer_index": 1, + }, + { + "question": "Which planet is known as the Red Planet?", + "options": ["Venus", "Jupiter", "Mars", "Saturn"], + "answer_index": 2, + }, + # ... a handful more, see examples/trivia-bot/questions.py for the full bank +] + + +def random_question() -> dict: + return random.choice(QUESTION_BANK) +``` + +واجهة `discord.py` الحديثة لهذا هي **أمر بشرطة مائلة**: بدلًا من مشاهدة كل رسالة بحثًا عن شيء يبدو كأمر، تسجّل `/trivia` لدى Discord نفسه، ويعرضه Discord في الواجهة مع إكمال تلقائي. يتطلب ذلك `Client` إضافةً إلى `app_commands.CommandTree` مربوط به: + +```python +# bot.py (Step 1 version — grows through the rest of this project) +import os + +import discord +from discord import app_commands +from dotenv import load_dotenv + +from questions import random_question + +load_dotenv() + +intents = discord.Intents.default() +intents.message_content = True # requires the portal toggle from Setup, too + +client = discord.Client(intents=intents) +tree = app_commands.CommandTree(client) + + +@tree.command(name="trivia", description="Start a trivia round") +async def trivia_command(interaction: discord.Interaction) -> None: + question = random_question() + lines = [f"**{question['question']}**"] + for letter, option in zip("ABCD", question["options"]): + lines.append(f"{letter}) {option}") + await interaction.response.send_message("\n".join(lines)) + + +@client.event +async def on_ready() -> None: + await tree.sync() # registers /trivia with Discord -- can take a minute the first time + print(f"Logged in as {client.user} -- ready in {len(client.guilds)} server(s).") + + +if __name__ == "__main__": + client.run(os.environ["DISCORD_BOT_TOKEN"]) +``` + +`tree.sync()` هو ما ينشر `/trivia` فعلًا إلى Discord ليظهر عندما يكتب أحدهم `/` في خادمك — تجاهله فيوجد الأمر في كودك لكن لا في أي مكان تستطيع واجهة Discord الوصول إليه. + +:::tip[أوامر الشرطة المائلة تحتاج نطاق OAuth2 ثانيًا] +دعوة بوت عادية تحتاج فقط إلى نطاق `bot`. تحتاج أوامر الشرطة المائلة تحديدًا إلى `applications.commands` أيضًا — إذا ولّدت رابط دعوتك قبل إضافة `/trivia`، فأعد توليده مع تحديد كلا النطاقين (انظر الإعداد أعلاه) أو لن يظهر الأمر أبدًا في خادمك بصمت. +::: + +**✅ قائمة التحقق** + + +يعرّف `questions.py` كلاً من `QUESTION_BANK` و`random_question()`. +يسجّل `bot.py` أمر شرطة مائلة `/trivia` عبر `app_commands.CommandTree`. +يستدعي `on_ready` `await tree.sync()` قبل طباعة رسالة الجاهزية. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +- يعيد `tree.sync()` تسجيل كل أمر شرطة مائلة لدى خوادم Discord، وهو مُقيَّد بالمعدل. ما الذي قد يسوء لو استدعيته داخل `trivia_command` بدلًا من مرة واحدة في `on_ready`؟ +- يشير `answer_index` في قاموس السؤال إلى `options` حسب الموضع بدلًا من تخزين نص الإجابة الصحيحة مباشرة. ما ميزة واحدة لتخزينه بهذه الطريقة؟ + +## الخطوة 2: تتبّع النقاط، محفوظ عبر الجولات + +لوحة المتصدّرين لا تعني شيئًا إلا إذا نجت من إعادة تشغيل البوت، لذا تذهب النقاط إلى ملف JSON صغير بدلًا من العيش في الذاكرة فقط: + +```python +# scores.py +"""Per-player score persistence in scores.json. Keyed by Discord user id +(not username), so a player's score survives a nickname change.""" + +import json +from pathlib import Path + +SCORES_PATH = Path("scores.json") + + +def load_scores() -> dict: + if not SCORES_PATH.exists(): + return {} + return json.loads(SCORES_PATH.read_text(encoding="utf-8")) + + +def save_scores(scores: dict) -> None: + SCORES_PATH.write_text(json.dumps(scores, indent=2), encoding="utf-8") + + +def award_point(scores: dict, user_id: int, display_name: str) -> dict: + key = str(user_id) + entry = scores.get(key, {"name": display_name, "score": 0}) + entry["name"] = display_name + entry["score"] += 1 + scores[key] = entry + save_scores(scores) + return scores + + +def leaderboard_text(scores: dict, top_n: int = 10) -> str: + if not scores: + return "No scores yet -- play a round with `/trivia`!" + ranked = sorted(scores.values(), key=lambda entry: entry["score"], reverse=True) + lines = [f"{i}. {entry['name']} — {entry['score']}" for i, entry in enumerate(ranked[:top_n], start=1)] + return "\n".join(lines) +``` + +اختبرها بمفردها قبل توصيلها بـ`bot.py` إطلاقًا — نفس نمط "أثبت أن القطعة تعمل بمفردها أولًا" كما في أي مشروع متعدد الأجزاء: + +```bash +uv run python -c " +from scores import award_point, leaderboard_text +s = {} +s = award_point(s, 111, 'Alice') +s = award_point(s, 222, 'Bob') +s = award_point(s, 111, 'Alice') +print(leaderboard_text(s)) +" +``` + +ثم أضف أمر شرطة مائلة ثانيًا يقرأ الملف فقط: + +```python +@tree.command(name="leaderboard", description="Show the trivia leaderboard") +async def leaderboard_command(interaction: discord.Interaction) -> None: + scores = load_scores() + await interaction.response.send_message(f"**Leaderboard:**\n{leaderboard_text(scores)}") +``` + +لا شيء يمنح نقطة بعد — لا يفحص `trivia_command` من الخطوة 1 الإجابات إطلاقًا — هذا ما تضيفه حلقة جولة الخطوة 4. هذه الخطوة هي عمدًا نصف التخزين فقط، مًختبرة وتعمل بمفردها أولًا. + +**✅ قائمة التحقق** + + +يعرّف `scores.py` كلاً من `load_scores()` و`award_point()` و`leaderboard_text()`. +تشغيل الاختبار المستقل لـ`scores.py` يطبع لوحة متصدّرين بترتيب Alice أعلى من Bob. +`/leaderboard` مسجّل في `bot.py` ويردّ بلوحة المتصدّرين (الفارغة بعد). + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +- تُحفظ النقاط بمفتاح `str(user_id)` بدلًا من اسم العرض للاعب. ما السيناريو الحقيقي الذي سيكسر لوحة متصدّرين مفتاحية بالأسماء ويصمد أمامه واحد مفتاحي بمعرّفات المستخدمين؟ +- تعيد `save_scores()` كتابة الملف بأكمله عند كل نقطة واحدة. لبوت صغير بخادم واحد هذا جيد — عند أي نقطة يتوقف هذا عن كونه جيدًا، وإلى ماذا ستلجأ بدلًا من ذلك؟ + +## الخطوة 3: ولّد سؤالًا جديدًا حول أي موضوع باستخدام LLM + +البنك الثابت في الخطوة 1 يسأل دائمًا من نفس الحفنة من الأسئلة. تضيف هذه الخطوة مصدر أسئلة ثانيًا: أعطِ البوت موضوعًا، فيسأل LLM عن سؤال اختيار من متعدد جديد كليًا عنه، في الحال. + +```python +# generate.py +"""Generates a fresh trivia question on a topic via a free-tier LLM. +Returns the exact same shape as questions.py's bank entries, so the rest +of the bot doesn't need to know or care where a question came from.""" + +import json +import os + +from openai import OpenAI + +llm_client = OpenAI( + api_key=os.environ["GITHUB_TOKEN"], + base_url="https://models.github.ai/inference", +) + +PROMPT_TEMPLATE = """Write one multiple-choice trivia question about: {topic} + +Respond with ONLY a JSON object, no other text, in exactly this shape: +{{"question": "...", "options": ["...", "...", "...", "..."], "answer_index": 0}} + +Requirements: +- Exactly 4 options. +- Exactly one is correct; put its index (0-3) in answer_index. +- The wrong options must be plausible, not obviously silly. +- Keep the question and every option short enough to fit in a Discord message.""" + + +def generate_question(topic: str) -> dict: + response = llm_client.chat.completions.create( + model="gpt-4o-mini", # confirm this still has a free tier before running + messages=[{"role": "user", "content": PROMPT_TEMPLATE.format(topic=topic)}], + response_format={"type": "json_object"}, + ) + question = json.loads(response.choices[0].message.content) + + options = question.get("options") + answer_index = question.get("answer_index") + if not question.get("question") or not isinstance(options, list) or len(options) != 4: + raise ValueError(f"LLM returned a malformed question: {question!r}") + if not isinstance(answer_index, int) or not (0 <= answer_index < 4): + raise ValueError(f"LLM returned an invalid answer_index: {question!r}") + return question +``` + +فحص الشكل الصريح بعد التحليل مهم: يضمن `response_format={"type": "json_object"}` أن مخرجات الـLLM *JSON صالح*، لا أنها *JSON الصحيح* — قد يُعيد ثلاثة خيارات بدلًا من أربعة، أو يحذف `answer_index` تمامًا. التقاط ذلك هنا، بخطأ واضح، أفضل من اكتشافه لاحقًا كرسالة Discord مربكة بفقدان الخيار D. + +اربط معامل `topic` في `/trivia` ليتمكن من السحب من أي من المصدرين: + +```python +from round import pick_question # combines random_question() and generate_question() +``` + +```python +# round.py +"""Non-Discord round logic shared by bot.py and the notebook.""" + +from generate import generate_question +from questions import random_question + + +def pick_question(topic: str | None = None) -> dict: + if topic: + return generate_question(topic) + return random_question() +``` + +```python +@tree.command(name="trivia", description="Start a trivia round, optionally on a topic") +@app_commands.describe(topic="Optional topic for a freshly generated question") +async def trivia_command(interaction: discord.Interaction, topic: str | None = None) -> None: + question = pick_question(topic) + ... +``` + +جرّب كلا المسارين من طرفية قبل الوثوق بهما داخل Discord: + +```bash +uv run python -c "from round import pick_question; print(pick_question())" +uv run python -c "from round import pick_question; print(pick_question('classic video games'))" +``` + +:::tip[تحقق من المحتوى المُولَّد بالـLLM قبل وصوله إلى قناة حيّة] +LLM طُلبت منه سؤال Trivia قد لا يزال يخطئ في الحقائق، خاصة حول المواضيع الغامضة — لا يوجد `try`/`except` يلتقط "الخطأ الواثق". فحص الشكل في `generate_question()` يحمي فقط من *بنية* معطوبة؛ لخادم عام، تصفّح حفنة من الأسئلة المُولَّدة حول مواضيع تعرفها فعلًا قبل الوثوق بالوضع في مواضيع لا تعرفها. +::: + +**✅ قائمة التحقق** + + +`generate_question(topic)` في `generate.py` تُعيد قاموسًا بأربعة خيارات و`answer_index` صالح، أو ترفع خطأً واضحًا. +`pick_question()` في `round.py` تُعيد سؤال بنك عندما يكون `topic` فارغًا، وسؤالًا مُولَّدًا بخلاف ذلك. +يقبل `/trivia` وسيط `topic` اختياريًا ويستخدمه بشكل مرئي. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +- تتحقق `generate_question()` من أن `answer_index` عدد صحيح في `0..3` وأن الخيارات أربعة بالضبط، لكنها لا تتحقق من أن *المحتوى* هو فعلًا معلومات Trivia صحيحة. أين الخط بين ما يمكن للكود فحصه بشكل معقول وما لا يمكن إلا لإنسان يراجع المخرجات فحصه؟ +- إذا اختار لاعب موضوعًا مسيئًا أو بلا معنى عن قصد، فما أسوأ شيء محتمل يمكن أن تُعيده `generate_question()`، وماذا ستضيف للحماية منه؟ + +## الخطوة 4: حلقة جولة Trivia كاملة + +كل شيء حتى الآن كان قطعًا مًختبرة في عزلة: مصدر أسئلة، وتخزين نقاط، وتوليد. تربط هذه الخطوة بينها فيما تبدو عليه الجولة فعلًا بشكل حيّ — انشر سؤالًا، وانتظر أول إجابة صحيحة ضمن مهلة زمنية، واكشفها، وحدّث لوحة المتصدّرين: + +```python +# bot.py (relevant part -- see examples/trivia-bot/bot.py for the full file) +import asyncio + +from round import OPTION_LETTERS, check_answer, format_question, pick_question +from scores import award_point, leaderboard_text, load_scores + +ROUND_TIME_LIMIT = 30 # seconds + + +async def run_round(channel: discord.abc.Messageable, topic: str | None = None) -> None: + question = pick_question(topic) + valid_letters = OPTION_LETTERS[: len(question["options"])] + await channel.send( + f"{format_question(question)}\n\nYou have {ROUND_TIME_LIMIT}s -- " + f"reply with just the letter ({'/'.join(valid_letters)})." + ) + + def is_candidate_answer(message: discord.Message) -> bool: + return ( + message.channel == channel + and not message.author.bot + and message.content.strip().upper() in valid_letters + ) + + loop = asyncio.get_event_loop() + deadline = loop.time() + ROUND_TIME_LIMIT + winner = None + + while True: + remaining = deadline - loop.time() + if remaining <= 0: + break + try: + message = await client.wait_for("message", check=is_candidate_answer, timeout=remaining) + except asyncio.TimeoutError: + break + if check_answer(question, message.content): + winner = message.author + break + await message.add_reaction("❌") + + correct_letter = OPTION_LETTERS[question["answer_index"]] + correct_text = question["options"][question["answer_index"]] + + if winner is not None: + scores = award_point(load_scores(), winner.id, str(winner.display_name)) + await channel.send( + f"✅ {winner.mention} got it! The answer was **{correct_letter}) {correct_text}**.\n\n" + f"**Leaderboard:**\n{leaderboard_text(scores)}" + ) + else: + await channel.send(f"⏰ Time's up! Nobody got it. The answer was **{correct_letter}) {correct_text}**.") +``` + +`client.wait_for("message", check=..., timeout=...)` هي طريقة `discord.py` في إيقاف دالة `async` مؤقتًا حتى يحدث نوع محدد من الأحداث — هنا، أي رسالة في نفس القناة محتواها حرف واحد بالضبط من أحرف الإجابة الصالحة. تستدعيها حلقة `while` مجددًا بمهلة `remaining` متناقصة، بحيث تكون الميزانية الزمنية *الإجمالية* للجولة هي `ROUND_TIME_LIMIT`، لا `ROUND_TIME_LIMIT` لكل تخمين خاطئ — دون إعادة حساب `remaining`، قد تُبقي قناة مليئة بالتخمينات الخاطئة المتحمّسة الجولة مفتوحة إلى أجل غير مسمّى. + +فقط الإجابة الصحيحة *الأولى* تسجّل؛ اكسر (`break`) بمجرد ضبط `winner`. تحصل التخمينات الخاطئة على تفاعل ❌ بدلًا من رسالة خطأ — تغذية راجعة مجانية دون إغراق القناة بالردود. + +أخيرًا، يصبح `trivia_command` من الخطوة 1 غلافًا رفيعًا حول `run_round`: + +```python +@tree.command(name="trivia", description="Start a trivia round, optionally on a topic") +@app_commands.describe(topic="Optional topic for a freshly generated question") +async def trivia_command(interaction: discord.Interaction, topic: str | None = None) -> None: + starting_text = f"🎲 Starting a round about **{topic}**..." if topic else "🎲 Starting a round..." + await interaction.response.send_message(starting_text) + try: + await run_round(interaction.channel, topic) + except Exception as error: # keep the bot alive even if one round fails + print(f"Error running trivia round: {error!r}") + await interaction.channel.send("Something went wrong running that round -- see the bot's console log.") +``` + +:::tip[اختبر توقيت الجولة بـROUND_TIME_LIMIT قصير أولًا] +اضبط `ROUND_TIME_LIMIT = 5` أثناء ضبطك للحلقة، حتى لا تنتظر 30 ثانية لكل دورة اختبار لتكتشف أن `check_answer` فيها خلل. أعد رفعه إلى شيء معقول للعب الحقيقي بمجرد أن تعمل الحلقة نفسها. +::: + +**✅ قائمة التحقق** + + +`/trivia` ينشر سؤالًا، ثم ينتظر فعلًا إجابة بدلًا من الحل فورًا. +تُعلَن أول إجابة صحيحة ضمن المهلة الزمنية فائزًا وتحصل على نقطة عبر `award_point()`. +ترك المؤقّت ينفد دون إجابة صحيحة يكشف الإجابة دون انهيار أو تعليق. +تشغيل `/trivia` مرتين متتاليتين يبدأ جولة جديدة في كل مرة، باستخدام لوحة المتصدّرين المحدّثة. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +- يفحص `is_candidate_answer` `message.channel == channel` حتى لا تُحتسب الإجابات من القنوات الأخرى في الخادم. ماذا سيحدث لجولة في خادم مزدحم لو غاب هذا الفحص؟ +- `try`/`except Exception` حول `run_round(...)` يلتقط *أي* استثناء وينشر خطأً عامًا بدلًا من الانهيار. ما المقايضة بين الالتقاط الواسع في بوت طويل التشغيل وبين ترك خلل حقيقي يُنهي العملية بصخب؟ + +## ادعُ البوت والعب جولة حقيقية + +باستخدام رابط OAuth2 الذي ولّدته في الإعداد (مع كلا النطاقين `bot` و`applications.commands`)، افتحه في متصفح واختر خادمًا تتحكم فيه — أنشئ خادم اختبار مجانيًا إذا لم يكن لديك واحد بالفعل. + +```bash +uv run python bot.py +``` + +يجب أن ترى `Logged in as trivia-bot#1234 -- ready in 1 server(s).` مطبوعة. في خادم الاختبار، اكتب `/trivia` واختره من قائمة الإكمال التلقائي في Discord — مع `topic` أو بدونه. خلال بضع ثوانٍ يجب أن ترى السؤال منشورًا، وبعد الإجابة الصحيحة (أو ترك المؤقّت ينفد) الإجابة مكشوفة ولوحة المتصدّرين محدّثة. شغّل `/leaderboard` في أي وقت لفحص النقاط دون بدء جولة جديدة. + +## ⚠️ مآزق شائعة + +- **نسيان نية "Message Content" المميزة.** يجب تفعيلها في *مكانين* — `intents.message_content = True` في الكود، **و** المفتاح تحت Bot → Privileged Gateway Intents في بوابة المطوّرين. أُغفل مفتاح البوابة ويكون `message.content` بصمت سلسلة فارغة لكل رسالة، لذا لا يطابق `is_candidate_answer` أي ردّ مهما كُتب. +- **الخلط بين رمز البوت وسرّ عميل OAuth2.** تعرض بوابة المطوّرين كليهما في تبويبين مختلفين. رمز البوت (تبويب Bot) هو ما يحتاجه `client.run(...)`؛ سرّ العميل (تبويب OAuth2) لتدفق مصادقة مختلف تمامًا لا يستخدمه هذا المشروع أبدًا. لصق سرّ العميل في `DISCORD_BOT_TOKEN` يفشل في تسجيل الدخول بخطأ مربك. +- **عدم ظهور `/trivia` أبدًا في واجهة Discord.** عادةً أحد سببين: `tree.sync()` لم يُستدعَ أبدًا (أو لم يُنتظَر) في `on_ready`، أو وُلِّد رابط دعوة البوت قبل إضافة نطاق `applications.commands`. أعد توليد رابط الدعوة مع كلا النطاقين وأعد دعوة البوت إذا كان الثاني هو المشكلة. +- **حدود المعدل على المستوى المجاني للـLLM، أسوأ مع عدة جولات متتالية.** كل استدعاء `/trivia ` طلب LLM منفصل ضد حصة مزوّدك المجانية، ويمكن لخادم مزدحم يشغّل عدة جولات متتالية أن يصطدم بها أسرع مما تتوقعه من الاختبار وحده. خطأ 429 ليس خللًا — أضف إعادة محاولة قصيرة مع تراجع حول `generate_question()`، أو ارجع إلى البنك الثابت عند فشل التوليد. +- **جولة لا تنتهي أبدًا لأن `remaining` لا يُعاد حسابه.** إذا نسخت حلقة الجولة لكنك استدعيت `client.wait_for(..., timeout=ROUND_TIME_LIMIT)` (الثابت) بدلًا من قيمة `remaining` المتناقصة، فإن كل تخمين خاطئ يعيد تشغيل الساعة فعليًا — يمكن أن تمتد الجولة أطول بكثير مما يعد به `ROUND_TIME_LIMIT` فعلًا. + +## ما بنيته للتو + +بوت Trivia حيّ على Discord بمصدرين للأسئلة — بنك ثابت وتوليد LLM مجاني حول أي موضوع — حلقة جولة كاملة بتوقيت حقيقي، ولوحة متصدّرين دائمة لكل لاعب تنجو من إعادة التشغيل. مصدر الأسئلة، والتسجيل، ومنطق الجولة (`questions.py`، و`generate.py`، و`scores.py`، و`round.py`) كلها Python عادي خالٍ من `discord`، مًختبرة بشكل مستقل قبل لمس أي قناة حيّة إطلاقًا؛ فقط `bot.py` يعرف بوجود Discord. هذا التقسيم يستحق وضعه في الاعتبار عمومًا: يمكن للوحدات الأربع نفسها أن تقف خلف بوت Slack، أو نموذج ويب، أو لعبة سطر أوامر بدلًا من ذلك، دون أي تغيير في أي منها. + +## إلى أين تذهب من هنا + +- أضف **وضع لعب متعدد الجولات** — `/trivia rounds:5` يشغّل عدة أسئلة متتالية ويعلن فائزًا إجماليًا في النهاية، بدلًا من سؤال واحد لكل أمر. +- تتبّع **وسوم الصعوبة أو الفئة** على الأسئلة المُولَّدة (اطلب من الـLLM تضمين وسم في استجابته JSON) ودع اللاعبين يختارون فئة عبر `/trivia topic:... difficulty:hard`. +- أضف **لوحة متصدّرين لكل خادم** بدلًا من `scores.json` عام واحد — مفتاح `scores.json` بـ`(guild_id, user_id)` بدلًا من `user_id` فقط، حتى لا يتشارك خادما Discord مختلفان يشغّلان هذا البوت لوحة متصدّرين. +- انشر البوت في مكان يبقى قيد التشغيل دون أن يعمل حاسوبك المحمول — آلة افتراضية صغيرة دائمة التشغيل، أو مستوى مجاني على منصة مثل Railway أو Fly.io — ليستمر في استضافة أمسيات Trivia حتى عندما لا تكون أمام جهازك. + +## شارك مشروعك مع الصف + +هل بنيت شيئًا تفتخر به؟ [`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/ar/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md b/i18n/ar/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md new file mode 100644 index 0000000..d140f27 --- /dev/null +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md @@ -0,0 +1,302 @@ +--- +id: 2027-webcam-object-counter +title: "عُدّ الأشياء في الوقت الفعلي باستخدام كاميرا ويب" +sidebar_label: "عدّاد الأشياء بكاميرا الويب" +slug: /projects/webcam-object-counter +description: "عُدّ الأشياء مباشرة من بثّ كاميرا ويب بـOpenCV ونموذج YOLO11n مُدرَّب مسبقًا — أو شغّل نفس الكشف على صورة أو فيديو نموذجي مرفق دون أي كاميرا على الإطلاق." +--- + +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 بمستوى 101 — الدوال، والحلقات، وتثبيت الحزم — ولا يحتاج أي خلفية سابقة في تحليل البيانات أو التعلم الآلي. إنها أول غزوة لهذا المساق في الرؤية الحاسوبية: بدلًا من تحميل نموذج مُدرَّب مسبقًا يقرأ نصًا أو صفوفًا جدولية، ستحمّل نموذجًا يقرأ بكسلًا، وتستخدمه للإجابة عن سؤال عملي حقًا في الوقت الفعلي — "كم عدد *هذا* أمام الكاميرا الآن؟" + +هذا اختياري وغير مُقيَّم. راجع [مشاريع من العالم الحقيقي](/docs/projects) للاطلاع على القائمة الكاملة والنامية. + +## 🎯 ما ستفعله + +1. تثبيت `uv` وإعداد مشروع محلي بـOpenCV ونموذج كشف أشياء مُدرَّب مسبقًا. +2. تشغيل الكشف على صورة نموذجية واحدة مرفقة ورسم صناديق إحاطة حول ما يجده. +3. عدّ الأشياء من فئة هدف واحدة (مثل `person`) وطباعة إجمالٍ متنامٍ. +4. معالجة فيديو نموذجي قصير مرفق إطارًا بإطار. +5. توصيل حلقة الكشف نفسها بكاميرا ويب الخاصة بك للعدّ الحي في الوقت الفعلي. + +## أين تُشغّل هذا + +**محليًا بـ`uv` هو السبيل الوحيد لتجربة كاميرا الويب الحية الكاملة.** كاميرا ويب فيزيائية موصولة بجهازك هي عتاد — لا يوجد طريق من تبويب متصفح يعمل في السحابة إلى كاميرا على مكتبك. تفترض الخطوات 1–5 أدناه هذا المسار، والخطوة 5 تحديدًا لن تعمل ببساطة في أي مكان آخر. + +- **GitHub Codespaces** يمنحك بيئة تطوير سحابية بدون أي إعداد (Node، وPython، و`uv` مثبَّتة بالفعل — انظر [`.devcontainer/devcontainer.json`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/.devcontainer/devcontainer.json))، والخطوات 1–4 (الصورة النموذجية، والعدّ، والفيديو النموذجي) تعمل بشكل جيد هناك. الخطوة 5 لن — فمساحة Codespace تعمل على خادم بعيد دون وصول إلى كاميرا ويب المحلية لديك أيضًا. +- **Google Colab أو Kaggle Notebooks أو Binder** مناسبة لنسخة **الصورة-النموذجية-فقط** من هذا المشروع، لا كاميرا الويب الحية. دفتر ملاحظات حقيقي وقابل للتشغيل يحمّل الصور النموذجية المرفقة ويشغّل كود الكشف نفسه موجود في [`examples/webcam-object-counter/notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/webcam-object-counter/notebook.ipynb) (سيشير إلى `main` بمجرد الدمج). انقر على شارة لتشغيله مباشرة: + + [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/webcam-object-counter/notebook.ipynb) + [![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/webcam-object-counter/notebook.ipynb) + [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fwebcam-object-counter%2Fnotebook.ipynb) + + كن صادقًا مع نفسك حول ما يمنحك إياه هذا: كشف الصور النموذجية فقط، لا بثّ كاميرا حي. إنها طريقة جيدة حقًا لرؤية النموذج يعمل دون أي تثبيت، لكنها ليست نفس مشروع الخطوة 5. + +## الإعداد + +`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 webcam-object-counter +cd webcam-object-counter +uv add opencv-python ultralytics +``` + +لا حاجة لأي مفتاح API في أي مكان في هذا المشروع — يعمل الكشف محليًا بالكامل، دون أي خدمة خارجية. انتبه للحجم، مع ذلك: `opencv-python` و`ultralytics` (التي تجرّ معها PyTorch) تحميل حقيقي — توقع أن يستغرق هذا `uv add` بضع دقائق وبضع مئات من الميجابايت من مساحة القرص في المرة الأولى. + +:::tip[طريقتان لكشف الأشياء — اختر ما يناسبك] +تشحن OpenCV **تسلسلات Haar** مدمجة — صغيرة وسريعة، بلا تحميل إضافي، لكنها ضيقة: كل تسلسل مُدرَّب لشيء محدد واحد (المثال الكلاسيكي هو `haarcascade_frontalface_default.xml` للوجوه الأمامية) ويعمل أفضل على رؤية أمامية نظيفة إلى حد معقول. يستخدم هذا المشروع بدلًا من ذلك **YOLO11n** عبر حزمة `ultralytics` — نموذج كشف أشياء صغير (بضعة ميجابايت) لكنه حديث حقًا، مُدرَّب مسبقًا على فئات الأجسام اليومية الثمانين لمجموعة بيانات COCO (شخص، وسيارة، وكلب، وحافلة، وكرسي، والمزيد)، يتعرّف على أكثر بكثير من الوجوه ويتعامل مع المشاهد الواقعية الفوضوية بشكل أفضل بكثير. المقايضة الصادقة: YOLO11n تثبيت أكبر وأبطأ قليلًا لكل إطار من تسلسل Haar، لكنه يكشف أشياء حقيقية، لا وجوهًا فقط، وهو بيت القصيد في مشروع "عدّ الأشياء" ذي الأغراض العامة. إذا كنت لا تحتاج إلا إلى كشف الوجوه، فإن تسلسل Haar بديل معقول تمامًا وأخف وزنًا يستحق معرفته. +::: + +## الخطوة 1: اكشف الأشياء في صورة نموذجية واحدة + +يعيد كل سكربت أدناه استخدام هذه الفكرة الأساسية نفسها. `yolo11n.pt` نقطة تحقق مُدرَّبة مسبقًا — يحمّلها `ultralytics` تلقائيًا في المرة الأولى التي تُنشئ فيها `YOLO(...)`، ويخزّنها محليًا بعد ذلك: + +```python +# detect_image.py +import cv2 +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") + +results = model("samples/street.jpg") +result = results[0] + +print(f"Detected {len(result.boxes)} object(s):") +for box in result.boxes: + class_name = model.names[int(box.cls)] + confidence = float(box.conf) + print(f" - {class_name} ({confidence:.0%} confidence)") + +annotated = result.plot() # draws boxes + labels on a copy of the image +cv2.imwrite("output_street.jpg", annotated) +``` + +```bash +uv run python detect_image.py +``` + +`model(image_path)` يشغّل خط أنابيب الكشف الكامل في استدعاء واحد: غيّر حجم الصورة، ومرّرها عبر الشبكة، وحوّل المخرجات الخام إلى قائمة صناديق، كل واحد مع تسمية فئة ودرجة ثقة. `result.boxes` هي تلك القائمة — `box.cls` مؤشر فئة داخل `model.names` (قاموس لكل أسماء فئات COCO الثمانين)، و`box.conf` هي ثقة النموذج بأن الصندوق يحتوي فعلًا تلك الفئة. `result.plot()` طريقة ملائمة ترسم كل ذلك على الصورة لك، بحيث لا تضطر إلى كتابة حلقة رسم الصناديق الخاصة بك بـ`cv2.rectangle`. + +**✅ قائمة التحقق** + + +تشغيل السكربت يطبع على الأقل جسمًا مكتشفًا واحدًا مع اسم فئة ودرجة ثقة. +يوجد `output_street.jpg`، وعند فتحه في عارض صور، يظهر صناديق مرسومة حول أشياء حقيقية في الصورة. +تستطيع أن تشرح، في جملة واحدة، ما يمثله كل من `box.cls` و`box.conf`. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +يعيد النموذج درجة ثقة لكل صندوق، لا مجرد نعم/لا "يوجد جسم هنا". إذا صفّيت أي صندوق بثقة أقل من 90%، هل تتوقع أن ترى كشوفات خاطئة أكثر أم كشوفات مفقودة أكثر — وأي من هذين الخطأين يهم أكثر لمشروع بيت قصده *عدّ* دقيق؟ + +## الخطوة 2: عدّ فئة هدف واحدة واحتفظ بإجمالٍ متنامٍ + +كشف كل شيء بداية جيدة، لكن "عدّ الأشياء" عادةً يعني عدّ *نوع واحد* من الأشياء — أشخاص يمشون عبر مدخل، سيارات في موقف، وهكذا: + +```python +# count_class.py +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") + +target_class = "person" +image_paths = ["samples/street.jpg", "samples/people.jpg"] + +running_total = 0 +for image_path in image_paths: + result = model(image_path, verbose=False)[0] + count = sum(1 for box in result.boxes if model.names[int(box.cls)] == target_class) + running_total += count + print(f"{image_path}: {count} {target_class}(s) -- running total: {running_total}") + +print(f"\nTotal {target_class}(s): {running_total}") +``` + +```bash +uv run python count_class.py +``` + +العدّ مجرد تصفية-وجمع فوق `result.boxes`، مقارنًا اسم فئة كل صندوق بالذي يهمك. يخفت `verbose=False` تسجيل `ultralytics` الخاص بكل استدعاء لكي لا تُدفن عبارات `print` الخاصة بك تحته. + +**✅ قائمة التحقق** + + +يطبع السكربت عددًا لكل صورة وإجماليًا متناميًا لا يرتفع إلا صعودًا. +تغيير `target_class` إلى فئة COCO مختلفة (مثل `"bus"`) يغيّر الأعداد المطبوعة وفقًا لذلك. +تفهم لماذا يعيد هذا استخدام `model.names[int(box.cls)]` بدلًا من ترميز رقم مؤشر فئة. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +إذا وقف شخصان في صورة متقاربين جدًا بحيث تتداخل صناديق إحاطتهما تقريبًا بالكامل، فهل هناك أي طريقة واقعية يمكن أن يعدّ بهما هذا النهج ناقصًا أو زائدًا؟ ماذا ستنظر إليه في `result.boxes` للتحقق؟ + +## الخطوة 3: عالج فيديو نموذجيًا قصيرًا إطارًا بإطار + +الفيديو مجرد تسلسل من الصور — نفس كود الكشف لكل صورة بالضبط من الخطوتين 1–2، يُشغَّل مرة واحدة لكل إطار في حلقة: + +```python +# detect_video.py +import cv2 +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") +target_class = "person" + +cap = cv2.VideoCapture("samples/sample_street.mp4") +fps = cap.get(cv2.CAP_PROP_FPS) or 15 +width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) +height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) +writer = cv2.VideoWriter("output_video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)) + +while True: + ok, frame = cap.read() + if not ok: + break # end of the video file, not a broken camera + + result = model(frame, verbose=False)[0] + count = sum(1 for box in result.boxes if model.names[int(box.cls)] == target_class) + + annotated = result.plot() + cv2.putText(annotated, f"{target_class}s: {count}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) + writer.write(annotated) + +cap.release() +writer.release() +``` + +```bash +uv run python detect_video.py +``` + +يقرأ `cv2.VideoCapture` ملف فيديو (أو، في الخطوة 4، كاميرا حية) إطارًا واحدًا في كل مرة عبر `.read()`، التي تعيد `(ok, frame)` — يكون `ok` هو `False` بمجرد عدم وجود أطر بعد. `cv2.VideoWriter` هو الفكرة نفسها بالعكس: إنه يراكم الأطر التي تسلّمها له في ملف فيديو جديد. لاحظ أن `if not ok: break` هنا تعني "انتهى الملف" — تعيد الخطوة 4 استخدام هذا الفحص نفسه بالضبط، لكنه هناك يعني شيئًا مختلفًا بأهمية. + +**✅ قائمة التحقق** + + +يوجد `output_video.mp4` ويُشغَّل، ويُظهر صناديق إحاطة وعددًا حيًا متراكبًا على كل إطار. +تستطيع أن تشرح ماذا تُعيد `cap.read()` ولماذا تفحص الحلقة `ok` قبل استخدام `frame`. +لاحظت أن العدد يمكن أن يومض من إطار لآخر حتى لو لم يتغير شيء في المشهد بشكل مرئي. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +العدد الذي تطبعه هو لقطة لكل إطار، لا إجماليًا لكل فيديو — تمرير نفس الشخص أمام الكاميرا لثلاث ثوانٍ قد يُعدّ في كل إطار. ماذا سيتطلب "عدّ كم شخصًا *مميزًا* عبر الإطار"، إلى جانب ما يفعله هذا السكربت حاليًا؟ + +## الخطوة 4: انطلق مباشرًا مع كاميرا الويب الخاصة بك + +نفس الحلقة، سطر واحد مختلف: بدّل مسار ملف الفيديو بـ`0`، مؤشر الكاميرا الافتراضية لجهازك: + +```python +# detect_webcam.py +import cv2 +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") +target_class = "person" + +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Could not open the webcam. Check that one is connected, that no other " + "app is using it, and that this program has camera permission.") +else: + print("Webcam opened. Press 'q' in the video window to quit.") + while True: + ok, frame = cap.read() + if not ok: + print("Lost the camera feed. Stopping.") + break + + result = model(frame, verbose=False)[0] + count = sum(1 for box in result.boxes if model.names[int(box.cls)] == target_class) + + annotated = result.plot() + cv2.putText(annotated, f"{target_class}s: {count}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) + cv2.imshow("Webcam Object Counter (press q to quit)", annotated) + + if cv2.waitKey(1) & 0xFF == ord("q"): + break + + cap.release() + cv2.destroyAllWindows() +``` + +```bash +uv run python detect_webcam.py +``` + +يفتح `cv2.VideoCapture(0)` كاميرتك الافتراضية بنفس الطريقة التي فتح بها `VideoCapture("some_file.mp4")` ملفًا في الخطوة 3 — نفس حلقة `.read()`، نفس شكل `(ok, frame)`. الفرقان المهمان: `.isOpened()` يُفحص *مقدمًا* هنا، بما أن "لا توجد كاميرا ويب متاحة" فشل حقيقي وشائع يجب أن يُنتج رسالة واضحة بدلًا من انهيار محيّر في عمق الحلقة؛ وبمجرد التشغيل، تحوّل `ok` إلى `False` في منتصف الحلقة يعني أن اتصال الكاميرا فُقد (فُصلت، أو سُحبت الصلاحية)، لا "وصلت إلى النهاية"، بما أن الكاميرا الحية لا نهاية لها. يفتح `cv2.imshow` نافذة حية — نافذة واجهة رسومية حقيقية، لذا لن يُنتج هذا السكربت مخرجات مرئية في طرفية بعيدة بسيطة دون شاشة. + +**✅ قائمة التحقق** + + +تُفتح نافذة تُظهر بثّ كاميرا الويب الحي الخاص بك مع صناديق إحاطة وعددًا متناميًا مرسومين عليها. +رفع عدد مختلف من الجسم الهدف (مثل نفسك، ثم نفسك وشخص ثانٍ) يغيّر العدد المطبوع/الظاهر على الشاشة وفقًا لذلك. +فصل الكاميرا أو تغطيتها أثناء التشغيل يُنتج رسالة "فُقد بثّ الكاميرا"، لا تعليقًا صامتًا. +الضغط على "q" يغلق النافذة بنظافة بدلًا من الحاجة إلى إجبار-إغلاق. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +تستخدم الخطوتان 3 و4 `if not ok: break` في نفس الموضع تمامًا في الكود، لكن هذا السطر يعني شيئًا مختلفًا في كل منهما ("نهاية الملف" مقابل "مشكلة في الكاميرا"). لماذا يستحق كتابة رسالة مميزة لكل حالة في الكود الحقيقي، بدلًا من معاملة كلتيهما كخطأ عام واحد؟ + +## ⚠️ مآزق شائعة + +- **رُفض إذن كاميرا الويب.** تطلب macOS وWindows الوصول إلى الكاميرا في المرة الأولى التي يحاول فيها تطبيق استخدامها — إذا تجاهلت تلك المطالبة (أو ظهرت خلف نافذة أخرى)، سيعيد `cv2.VideoCapture(0).isOpened()` قيمة `False` حتى مع كاميرا تعمل بشكل مثالي. تحقق من إعدادات خصوصية الكاميرا في نظام تشغيلك لتطبيق الطرفية أو مترجم Python خاصتك تحديدًا. +- **التشغيل الأول بطيء ويحتاج اتصالًا بالإنترنت.** يحمّل `ultralytics` ملف `yolo11n.pt` من خوادم Ultralytics في المرة الأولى التي تُنشئ فيها `YOLO(...)` — بعد ذلك يُخزَّن محليًا (عادةً تحت `~/.cache` أو الدليل الحالي) وكل تشغيل لاحق دون اتصال بالكامل. إذا بدا أن التشغيل الأول علق، فالأرجح أنه ما زال يحمّل، لا عالق. +- **الخلط بين "لم يُكتشف أي جسم" و"الكاميرا لا تعمل".** يبدوان متطابقين للوهلة الأولى — عدد فارغ في كلتا الحالتين — لكن لهما إصلاحين مختلفين تمامًا. افحص `cap.isOpened()` وما إذا كان `cv2.imshow` يُظهر صورة حية على الإطلاق *قبل* القلق حول سبب كون العدد صفرًا؛ بثّ يعمل مع عدد فارغ حقًا (لا يوجد في الإطار ما يطابق فئة هدفك) ليس خطأ. +- **عدم تطابق مؤشر الكاميرا على أجهزة بأكثر من كاميرا.** يفتح `VideoCapture(0)` الكاميرا التي يعتبرها نظام تشغيلك الافتراضية، وهي ليست دائمًا التي تتوقعها على حاسوب محمول مع كاميرا ويب خارجية موصولة — جرّب `1`، و`2`، إلخ إذا كان `0` يفتح الخاطئة. + +## ما بنيته للتو + +خط أنابيب رؤية حاسوبية حقيقي وعامل: حمّل نموذجًا مُدرَّبًا مسبقًا، وشغّله على بكسل بدلًا من صفوف أو نص، وحوّل مخرجاته الخام (صناديق، ومؤشرات فئة، ودرجات ثقة) إلى شيء يريده شخص فعليًا — عدًّا حيًا لنوع محدد من الأشياء. نفس الشكل المكوّن من ثلاث خطوات (كشف لكل صورة → تصفية إلى فئة واحدة → حلقة على الأطر) يتدرج من صورة واحدة إلى بثّ كاميرا حي حقًا مع تغيّر مصدر الإدخال فقط. + +:::tip[هذا يُعمَّم أبعد من "عدّ الأشياء"] +كل شيء هنا — كاشف مُدرَّب مسبقًا، حلقة على الأطر، عدّ متنامٍ — هو أيضًا العمود الفقري لأشياء مثل حساسات عدّ الأشخاص عند مداخل المتاجر، وكاميرات عدّ المرور الأساسية، وعدّادات الأنواع في كاميرات الفخ في الحياة البرية. منطق العدّ في الخطوة 2 مبسَّط عمدًا (بلا تتبع للأجسام بين الأطر، لذا فالشخص الذي يقف ثابتًا لعشرة أطر يُعدّ في العشرة جميعًا)، وهو تبسيط صادق، لا خطأ مخفي — انظر قسم "إلى أين تذهب من هنا" لما تضيفه الأنظمة الحقيقية فوقه. +::: + +## إلى أين تذهب من هنا + +- **تتبع الأشياء، لا الكشف فقط.** يشير السؤال السقراطي في الخطوة 3 إلى الفجوة الحقيقية: يعدّ هذا المشروع الأشياء *لكل إطار*، لا أشياء مميزة *عبر* الفيديو. مكتبات مثل وضع التتبع المدمج الخاص بـ`ultralytics` نفسه (`model.track(...)`، باستخدام خوارزميات مثل ByteTrack) تُسند معرّفًا دائمًا لكل جسم عبر الأطر، لذا تصبح "كم شخصًا *مميزًا* عبر الإطار" قابلة للإجابة بدلًا من مجرد "كم في الإطار الآن؟" +- **نموذج أكبر وأدق.** يتاجر `yolo11n.pt` (علامة "n" تعني nano) ببعض الدقة مقابل السرعة والحجم. يشحن `ultralytics` نقاط تحقق أكبر (`yolo11s.pt`، و`yolo11m.pt`، وما فوق) تكشف بشكل أكثر موثوقية، خاصةً على الأجسام الصغيرة أو المغطاة جزئيًا، على حساب الحاجة لحوسبة أكثر لكل إطار — يستحق التجربة إذا بدت أعداد الخطوة 4 الحية غير موثوقة على إعدادك الخاص. +- **فئة مخصصة، لا فئات COCO الثمانين فقط.** يتعرّف YOLO11n فقط على ما تدرب عليه. الضبط الدقيق لنموذج YOLO على صورك المصنفة الخاصة (نسخة أصغر بكثير من الفكرة نفسها كـ[مشروع Fine-tune a Small Language Model](/docs/projects/finetune-llm-unsloth)) يتيح لك عدّ شيء لم تضمّنه COCO أبدًا — منتج محدد على رف، أداة محددة، أي شيء يمكنك تصنيف بضع مئات من الأمثلة منه. + +## شارك مشروعك مع الصف + +بنيت شيئًا فخورًا به؟ [`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/ar/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md b/i18n/ar/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md new file mode 100644 index 0000000..bb8dcdb --- /dev/null +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md @@ -0,0 +1,332 @@ +--- +id: wordle-clone +title: "ابنِ نسخة من Wordle" +sidebar_label: "نسخة من Wordle" +slug: /projects/wordle-clone +description: "ابنِ لعبة Wordle حقيقية تعمل في الطرفية من الصفر: تغذية راجعة صحيحة للتخمينات بالأخضر/الأصفر/الرمادي (بما في ذلك خلل الحروف المتكررة الكلاسيكي)، وقائمة كلمات مخصصة، وتتبع إحصائيات دائم عبر الجلسات." +--- + +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'; + +# 🌍 ابنِ نسخة من Wordle + + + + + +يفترض هذا المشروع فقط الأساسيات بمستوى Python 101 — الدوال، والقوائم، والقواميس، والحلقات، وقراءة وكتابة ملف. بلا pandas، بلا مفتاح API، بلا GPU، بلا أي خدمة خارجية من أي نوع — فقط طرفية، وقائمة كلمات، وبعض المنطق الذي إتقانه أصعب مما يبدو. هذا ما يجعله مشروعًا من العالم الحقيقي *أبكر* ممتازًا لتجربته، حتى قبل بعض المشاريع المتعلقة بـpandas أو الذكاء الاصطناعي: كل ما تحتاجه هو أشياء قد أعطتك إياها Python 101 بالفعل، مطبَّقة على شيء ممتع فعلًا للعب بعده. + +هذا اختياري وغير مُقيَّم. راجع [مشاريع من العالم الحقيقي](/docs/projects) للاطلاع على القائمة الكاملة والنامية. + +## 🎯 ما ستفعله + +1. تنفيذ المنطق الأساسي لتغذية راجعة التخمين — مقارنة تخمين بالكلمة المستهدفة وإنتاج علامات خضراء/صفراء/رمادية لكل حرف، ومعالجة الحروف المتكررة بشكل صحيح (خلل منطق Wordle الكلاسيكي). +2. بناء حلقة لعب تفاعلية مدعومة بقائمة كلمات حقيقية، مع منح اللاعب 6 تخمينات. +3. التحقق من صحة التخمينات مقابل قائمة الكلمات وإعطاء تغذية راجعة واضحة عندما يُرفَض التخمين. +4. إضافة تتبع إحصائيات دائم — معدل الفوز، والسلسلة الحالية، وتوزيع عدد التخمينات — محفوظ في ملف JSON محلي ليبقى بين التشغيلات. + +## أين تُشغّل هذا + +- **محليًا باستخدام `uv` (موصى به).** لا يحتاج هذا المشروع شيئًا وراء المكتبة القياسية بالإضافة إلى مكتبة ألوان طرفية صغيرة — مرشح جيد لتثبيت Python فعليًا على جهازك الخاص. يشرح قسم الإعداد أدناه ذلك خطوة بخطوة، وتتبع الخطوات 1–4 هذا المسار. +- **GitHub Codespaces.** افتح [codespaces.new/abderrahim-lectures/python-data-analysis-course](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) للحصول على بيئة تطوير سحابية مع تثبيت Node وPython و`uv` مسبقًا (راجع [`.devcontainer/devcontainer.json`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/.devcontainer/devcontainer.json)) — نفس الأوامر أدناه تعمل من تبويب متصفح، دون أي تثبيت محلي على الإطلاق. +- **Google Colab وKaggle Notebooks أو Binder.** لا يحتاج هذا المشروع إلى أي تبعيات خارجية، ما يجعله مناسبًا ممتازًا لدفتر الملاحظات بمعنى ما — لكن موجه `input()` في دفتر الملاحظات مختلف قليلًا عن طرفية تفاعلية حقيقية: لا إعادة رسم للبلاطات الملونة في مكانها على سطر واحد، و(على Colab/Kaggle) لا تنجو الملفات المحلية للجلسة بشكل موثوق بين زيارات منفصلة، وهو ما يضرب ضد جزء "تستمر الإحصائيات عبر الجلسات" من هذا المشروع. يظل [`notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/wordle-clone/notebook.ipynb) نسخة حقيقية قابلة للعب — تستحق التجربة — فقط اعلم أن التجربة الكاملة (البلاطات الملونة في الطرفية، والإحصائيات التي تستمر بين أيام لعب منفصلة) هي فعليًا شيء "شغّله محليًا". + + [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/wordle-clone/notebook.ipynb) + [![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/wordle-clone/notebook.ipynb) + [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fwordle-clone%2Fnotebook.ipynb) + + {/* Badges point at this PR's branch; will point at `main` once merged. */} + +## الإعداد + +`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 wordle-clone +cd wordle-clone +uv add rich +``` + +`rich` هي التبعية الخارجية الوحيدة التي يحتاجها هذا المشروع بأكمله، وتُستخدم فقط لإخراج الألوان في الطرفية (البلاطات الخضراء/الصفراء/الرمادية) — كل جزء من منطق اللعبة الفعلي أدناه هو Python قياسي من المكتبة القياسية. بلا مفتاح API، بلا تسجيل، لا شيء لإعداده قبل أن تستطيع تشغيل سطر واحد من الكود. + +## الخطوة 1: سجّل تخمينًا مقابل الكلمة المستهدفة + +ابدأ بالجزء الذي من السهل أن يُتقَن *تقريبًا* ومن المُرضي أن يُتقَن *فعليًا*: بوجود تخمين من 5 حروف وكلمة مستهدفة من 5 حروف، أنتج علامة واحدة لكل حرف — أخضر إذا كان ذلك الحرف في الموضع الصحيح، وأصفر إذا كان في الكلمة لكن في الموضع الخاطئ، ورمادي في غير ذلك. + +يميل المحاولة الأولى إلى الظهور هكذا، مع فحص كل حرف مُخمَّن بشكل مستقل: + +```python +# A tempting first version — has a bug, keep reading +def score_guess_naive(guess: str, target: str) -> list[str]: + marks = [] + for i, letter in enumerate(guess): + if letter == target[i]: + marks.append("G") + elif letter in target: + marks.append("Y") + else: + marks.append("X") + return marks +``` + +جرّبه على `guess = "SPEED"`, `target = "ERASE"`. تحتوي الكلمة المستهدفة على **حرف** `E` واحد بالضبط. تفحص النسخة الساذجة كل حرف مُخمَّن مقابل سلسلة الهدف كاملة بشكل مستقل — لذا يُفحَص *كلا* حرفي `E` في `SPEED` مقابل `"E" in target`، وهو `True` في المرتين، ويُعلَّم كلاهما باللون الأصفر. هذا خطأ: لن يمنح Wordle الحقيقي أبدًا حرفي `E` أصفرين في تخمين عندما تحتوي الكلمة المستهدفة على `E` واحد فقط — حرف `E` مُخمَّن واحد يستحق علامة، والآخر لا تبقى لديه حرف مطابق يبرر واحدة. + +الحل خوارزمية من تمريرين: + +```python +from collections import Counter + +WORD_LENGTH = 5 + +def score_guess(guess: str, target: str) -> list[str]: + guess, target = guess.upper(), target.upper() + marks = ["X"] * WORD_LENGTH + + # Pass 1: greens, and tally which target letters are still "available" + # (i.e. not already accounted for by a green) for the yellow pass. + remaining = Counter() + for i, (g, t) in enumerate(zip(guess, target)): + if g == t: + marks[i] = "G" + else: + remaining[t] += 1 + + # Pass 2: yellows, consuming from that same pool of remaining letters + # so a letter can never be flagged more times than it truly occurs. + for i, g in enumerate(guess): + if marks[i] == "G": + continue + if remaining[g] > 0: + marks[i] = "Y" + remaining[g] -= 1 + # else stays "X" + + return marks +``` + +يميّز التمرير الأول كل تطابق في الموضع الصحيح بالأخضر، ويُحصي بشكل منفصل (في `remaining`) كم نسخة من كل حرف هدف *غير أخضر* ما تزال "متاحة للأخذ". ثم يعيد التمرير الثاني المرور على التخمين: أي حرف ليس أخضر بالفعل لا يحصل على علامة صفراء إلا إذا ما زال في `remaining` نسخة غير مُطالَبة منه — والمطالبة بواحدة تنقص العد، لذا لن تحصل نسخة مُخمَّنة ثانية من نفس الحرف على أصفر أيضًا إلا إذا كان للهدف فعلًا نسخة ثانية أيضًا. + +شغّله على الحالة الصعبة: + +```python +print(score_guess("SPEED", "ERASE")) # ['Y', 'X', 'Y', 'Y', 'X'] +``` + +حرف `E` واحد (الموضع 0) أصفر، والآخر (الموضع 3) أصفر أيضًا لأن `ERASE` يحتوي فعلًا على حرفي `E` — لكن تخمينًا مثل `"ELITE"` مقابل كلمة مستهدفة بحرف `E` واحد فقط سيعطي *الثاني* `E` رماديًا بشكل صحيح، لا أصفر. + +**✅ قائمة التحقق** + + +`score_guess("CRANE", "CRANE")` returns all greens. +`score_guess("SPEED", "ERASE")` returns exactly two yellow `E`s, not more. +A guess and target that share zero letters returns all grays. +You've tried a case where the *guess* repeats a letter but the target only has one copy, and confirmed only one mark comes back non-gray. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +جرّب الهدف `"LLAMA"` والتخمين `"ALLOY"` يدويًا قبل تشغيل الكود: يحتوي `LLAMA` على حرفي `L` وحرفي `A`. مرّر على التمريرين بنفسك — أي الحروف تنتهي خضراء، وأيها صفراء، وأيها رمادية؟ ثم تحقق من إجابتك مقابل `score_guess`. لو أخطأت على الورق، أين تحديدًا اختلف نموذجك الذهني عن خوارزمية التمريرين؟ + +## الخطوة 2: ابنِ حلقة اللعبة + +بعد إتقان التسجيل، لفِّه في لعبة فعلية: اختر هدفًا عشوائيًا من قائمة كلمات، وامنح اللاعب 6 تخمينات، وتوقف بمجرد أن يحصل على الخمسة الخضراء. + +```python +import random + +MAX_GUESSES = 6 + +def load_words(path="words.txt") -> list[str]: + with open(path) as f: + return [w.strip().upper() for w in f if w.strip()] + +def play_round(words: list[str]) -> tuple[bool, int]: + target = random.choice(words) + for attempt in range(1, MAX_GUESSES + 1): + guess = input(f"Guess {attempt}/{MAX_GUESSES}: ").strip().upper() + marks = score_guess(guess, target) + print(" ".join(f"{l}:{m}" for l, m in zip(guess, marks))) + if all(m == "G" for m in marks): + print(f"You got it in {attempt}!") + return True, attempt + print(f"Out of guesses. The word was {target}.") + return False, MAX_GUESSES +``` + +`words.txt` ملف نصي عادي، كلمة واحدة في كل سطر — يرفق المثال الحقيقي قائمة من نحو 540 كلمة إنجليزية شائعة من 5 حروف لهذا الغرض تحديدًا. *قائمة* كلمات كهذه (مجرد حقائق حول أي السلاسل كلمات إنجليزية، بلا تعبير إبداعي) من المقبول استخدامها وإعادة توزيعها بحرية، على عكس نسخ، مثلًا، التعريفات الفعلية لقاموس. + +**✅ قائمة التحقق** + + +Each round picks a genuinely random target from the word list (print it temporarily to confirm, then remove the print — no spoilers once you trust it). +The loop stops immediately once all five marks are green, even before 6 guesses are used. +After exactly 6 wrong guesses, the loop ends and reveals the target. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +لو استُدعيت `random.choice(words)` مرة واحدة لكل جولة من داخل `play_round`، واستدعيت `play_round` في حلقة للسماح لشخص باللعب مجددًا، هل سيتغير الهدف فعلًا بين الجولات؟ ماذا سيحدث لو حسبت `target` عن طريق الخطأ مرة واحدة *خارج* الحلقة بدلًا من ذلك؟ + +## الخطوة 3: تحقق من صحة التخمينات مقابل قائمة الكلمات + +لا يسمح لك Wordle الحقيقي بتخمين `"ZZZZZ"` — كل تخمين يجب أن يكون كلمة حقيقية من قاموسه. أضف ذلك الفحص قبل التسجيل: + +```python +def read_guess(word_set: set[str]) -> str: + while True: + raw = input(f"Guess ({WORD_LENGTH} letters): ").strip().upper() + if len(raw) != WORD_LENGTH or not raw.isalpha(): + print(f" Please enter exactly {WORD_LENGTH} letters.") + continue + if raw not in word_set: + print(f" '{raw}' isn't in the word list — try a real word.") + continue + return raw +``` + +استخدام `set` هنا بدلًا من فحص `raw in words` ضد القائمة مباشرة يهم أكثر مما يبدو: تفحص عمليات عضوية القائمة كل إدخال واحدًا تلو الآخر، بينما فحص المجموعة شبه فوري بغض النظر عن عدد الكلمات فيها — عادة صغيرة لكنها جيدة فعلًا لأي فحص "هل هذه القيمة في مجموعة كبيرة؟". + +:::tip[ارفض المدخلات السيئة مبكرًا، لا في منتصف اللعبة] +التحقق من *شكل* التخمين (5 حروف، أبجدي) قبل فحص قائمة الكلمات يلتقط أكثر أخطاء الكتابة شيوعًا بأرخص فحص أولًا — لا جدوى من البحث عن `"crane5"` في مجموعة من 540 كلمة عندما يخبرك فحص `len()` و`.isalpha()` بالفعل أنه غير سليم. +::: + +**✅ قائمة التحقق** + + +Guessing a non-word (e.g. `"ZZZZZ"`) prints a clear rejection message and re-prompts, without consuming one of the 6 tries. +Guessing something that isn't 5 letters (too short, too long, contains a digit) is also rejected before it ever reaches the word-list check. +A valid, in-list guess is accepted immediately, lowercase or uppercase. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +لماذا يهم أن يعيد `read_guess` السؤال عند تخمين سيئ *داخل حلقته الخاصة*، بدلًا من إعادة قيمة حارسة مثل `None` ليتعامل معها المتصل (`play_round`)؟ ماذا سيسوء في عدّ المحاولات في الخطوة 2 لو سُمح لتخمين غير صالح بأن يستهلك واحدة من المحاولات الست؟ + +## الخطوة 4: أضف تتبع إحصائيات دائم + +القطعة الأخيرة: تذكّر أداء اللاعب عبر تشغيلات منفصلة للبرنامج، لا فقط داخل جلسة واحدة. ذلك يعني الكتابة إلى ملف على القرص. + +```python +import json +from pathlib import Path + +STATS_FILE = Path("stats.json") + +DEFAULT_STATS = { + "played": 0, + "wins": 0, + "current_streak": 0, + "max_streak": 0, + "guess_distribution": {str(n): 0 for n in range(1, MAX_GUESSES + 1)}, +} + +def load_stats() -> dict: + if not STATS_FILE.exists(): + return json.loads(json.dumps(DEFAULT_STATS)) # a fresh copy + with STATS_FILE.open() as f: + return json.load(f) + +def save_stats(stats: dict) -> None: + with STATS_FILE.open("w") as f: + json.dump(stats, f, indent=2) + +def record_result(stats: dict, won: bool, guesses_used: int) -> dict: + stats["played"] += 1 + if won: + stats["wins"] += 1 + stats["current_streak"] += 1 + stats["max_streak"] = max(stats["max_streak"], stats["current_streak"]) + stats["guess_distribution"][str(guesses_used)] += 1 + else: + stats["current_streak"] = 0 + return stats +``` + +يعالج `load_stats` التشغيل الأول برشاقة — لا يوجد ملف بعد، لذا يعيد مجموعة افتراضيات جديدة مُصفَّرة إلى الصفر بدلًا من الانهيار بسبب ملف مفقود. يحمّل كل تشغيل آخر ما حُفِظ في المرة السابقة. يضيف `record_result` إلى `guess_distribution` فقط عند الفوز — الخسارة لا تحمل قيمة ذات معنى لـ"التخمينات المستخدمة للفوز"، تمامًا مثل شاشة إحصائيات Wordle الحقيقية نفسها. + +حلقة اللعبة الكاملة تربط كل شيء: حمّل الإحصائيات مرة واحدة عند بدء التشغيل، وحدّثها واحفظها بعد كل جولة. + +```python +words = load_words() +stats = load_stats() + +while True: + won, attempts = play_round(words) + stats = record_result(stats, won, attempts) + save_stats(stats) + print(f"Played: {stats['played']} Win rate: {stats['wins']/stats['played']:.0%} " + f"Streak: {stats['current_streak']}") + if input("Play again? [y/N] ").strip().lower() != "y": + break +``` + +:::tip[احفظ بعد كل جولة، لا فقط عند الخروج] +استدعاء `save_stats(stats)` مباشرة بعد `record_result`، في كل جولة، يعني أن برنامجًا مُقاطَعًا (طرفية مغلقة، `Ctrl+C`، انهيار) يفقد في أسوأ الأحوال نتيجة الجولة *الحالية* فقط — أبدًا تقدم الجلسة كلها. الحفظ مرة واحدة فقط في نهاية البرنامج تمامًا سيرمي كل شيء إذا غادر اللاعب في منتصف الجلسة بدلًا من الخروج عبر موجه "اللعب مجددًا؟". +::: + +**✅ قائمة التحقق** + + +Quitting the program and restarting it shows the same `played`/`wins`/streak numbers as before you quit, loaded from `stats.json`. +Winning in, say, 3 guesses increments `guess_distribution["3"]` specifically, not some other key. +Losing a round resets `current_streak` to 0 but does not touch `guess_distribution` or `max_streak`. +Deleting `stats.json` and rerunning the program doesn't crash — it starts a fresh, zeroed stats file instead. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +تُحسَب `max_streak` كـ`max(stats["max_streak"], stats["current_streak"])` بعد كل فوز، بدلًا من تحديثها فقط عندما تنتهي *اللعبة*. لماذا يتبع تحديثها بعد كل فوز على حدة (بدلًا من محاولة حسابها لاحقًا من السجل) أفضل سلسلة وصل إليها اللاعب بشكل صحيح، حتى لو ما زال اللاعب على أفضل سلسلة له الآن ولم يخسر بعد؟ + +## ⚠️ مآزق شائعة + +- **خلل الحروف المتكررة (الخطوة 1).** بفارق كبير الخطأ الأكثر شيوعًا: فحص `letter in target` بشكل مستقل لكل حرف مُخمَّن، دون تتبع أي نسخ من حرف متكرر "طُولِب بها" بالفعل. يمنح هذا علامات صفراء أكثر مما يجب كلما كرر التخمين أو الهدف حرفًا. استخدم دائمًا نهج التمريرين الذي يستهلك النسخ — الخضراء أولًا، ثم الصفراء ضد مجموعة حروف الهدف *المتبقية*. +- **تخمينات ليست كلمات حقيقية.** دون التحقق من الصحة مقابل قائمة الكلمات (الخطوة 3)، يمكن للاعبين تخمين `"AEIOU"` أو أي غير كلمة أخرى فقط لاستكشاف أي الحروف في الهدف — استراتيجية يحظرها Wordle الحقيقي صراحةً بفرض أن كل تخمين كلمة قاموسية. +- **حساسية حالة الأحرف.** `"crane" == "CRANE"` تساوي `False` في Python. وحّد حالة كل تخمين وهدف إلى نفس الحالة (يستخدم هذا المشروع `.upper()` في كل مكان) لحظة دخولها إلى كودك، وإلا فستفشل المقارنات بصمت لتخمينات صحيحة تمامًا. +- **فقدان الإحصائيات عند الانهيار.** كتابة `stats.json` مرة واحدة فقط عند الخروج من البرنامج تعني أن أي انهيار، أو `Ctrl+C`، أو طرفية مغلقة يفقد تقدم تلك الجلسة كلها. احفظ بعد كل جولة بدلًا من ذلك (راجع النصيحة في الخطوة 4). +- **ملف إحصائيات من نسخة أقدم من كودك.** لو أضفت حقلًا جديدًا إلى `DEFAULT_STATS` لاحقًا، فسيحمّل `load_stats` كما هو مكتوب أعلاه بسعادة ملف `stats.json` *قديمًا* يفتقد ذلك الحقل، ثم ينهار أول مرة يحاول كودك قراءته. يستحق المعالجة الدفاعية (انظر كيف يدمج `examples/wordle-clone/stats.py` البيانات المحمَّلة فوق نسخة جديدة من الافتراضيات) لو كنت تخطط لمواصلة تعديل مخطط الإحصائيات. + +## ما بنيته للتو + +نسخة Wordle حقيقية: منطق تغذية راجعة صحيح للتخمينات (بما في ذلك الحالة الحدّية للحروف المتكررة التي تتعثر فيها كثير من المحاولات الأولى)، وحلقة لعب تفاعلية مدعومة بقائمة كلمات حقيقية مع تحقق مناسب من صحة التخمين، وإحصائيات تستمر فعلًا عبر تشغيلات منفصلة للبرنامج — لا فقط داخل جلسة واحدة. لم يحتج أي من ذلك إلى شيء وراء المكتبة القياسية ومكتبة ألوان صغيرة واحدة، وهو ما يستحق الانتباه إليه: يمكن أن يكون المشروع متينًا وممتعًا فعلًا دون الحاجة إلى مفتاح API، أو إطار عمل، أو خدمة سحابية. + +:::tip[تحقق من المنطق الصعب بحالات اختبار، لا فقط باختبار اللعب] +من السهل لعب بضع جولات، ورؤية مخرجات تبدو معقولة، وافتراض أن منطق التسجيل صحيح — لكن خلل الحروف المتكررة تحديدًا لا يظهر إلا على تخمينات أو أهداف بحروف متكررة، وهي لا تأتي في كل جولة تصادف أن تلعبها يدويًا. كتابة حفنة من حالات الاختبار الصريحة (مثل مثال `SPEED`/`ERASE` في الخطوة 1) التي تستهدف تلك الحالة الحدّية تحديدًا تلتقط أخطاء قد يفوتها اختبار اللعب العادي تمامًا. +::: + +## إلى أين تذهب من هنا + +- **الوضع الصعب.** يتطلب الوضع الصعب في Wordle الحقيقي أن يعيد كل تخمين لاحق استخدام أي أخضر/أصفر أُظهِر بالفعل — فرض ذلك يعني تتبع القيود المعروفة عبر التخمينات داخل الجولة، لا فقط تسجيل تخمين واحد بمعزل. +- **نظام تلميحات.** اكشف الموضع الصحيح لحرف واحد عشوائي لم يُخمَّن عند الطلب، على حساب عدّه ضد إجمالي تخمينات اللاعب (أو أي مقايضة أخرى تصممها). +- **لعب متعدد أو كلمة يومية مشتركة.** يشتهر Wordle الحقيقي بإعطاء الجميع نفس الكلمة كل يوم. اشتقاق هدف اليوم بشكل حتمي من التاريخ (مثل تجزئة سلسلة التاريخ لاختيار فهرس في قائمة الكلمات) سيسمح لكل لاعب برؤية نفس الكلمة دون خادم — تمرين صغير لطيف في العشوائية الحتمية. +- **محلِّل حلال بسيط، كهدف طموح.** بوجود العلامات المُعادة حتى الآن، صفِّ قائمة الكلمات إلى الكلمات المتوافقة فقط مع كل قيد أُظهِر — انعكاس ممتع لمنطق اللعبة الذي كتبته للتو، وتمرين جيد في نفس منطق الحروف المتكررة من الخطوة 1، مطبَّقًا في الاتجاه المعاكس. + +## شارك مشروعك مع الصف + +بنيت شيئًا فخورًا به؟ [`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 870d585..31fe223 100644 --- a/i18n/es/code.json +++ b/i18n/es/code.json @@ -933,5 +933,13 @@ "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" + }, + "homepage.projects.triviaBot.title": { + "message": "Construye un Bot de Trivia para Discord", + "description": "Homepage project card title" + }, + "homepage.projects.triviaBot.summary": { + "message": "Ejecuta rondas de trivia en un servidor de Discord con discord.py: una tabla de clasificación persistente, y preguntas generadas al momento sobre cualquier tema con un LLM de nivel gratuito.", + "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 9b9586d..e198698 100644 --- a/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -166,6 +166,10 @@ Son opcionales y no calificados. Explóralos en cualquier momento — la introdu 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.', + id: 'trivia-bot', + title: 'Construye un Bot de Trivia para Discord', + summary: + 'Construye un bot de `discord.py` que ejecuta rondas de trivia en un servidor, lleva el seguimiento de los puntos en una tabla de clasificación persistente, y puede generar preguntas nuevas sobre cualquier tema con un LLM de nivel gratuito.', }, ])} /> diff --git a/i18n/es/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json b/i18n/es/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json new file mode 100644 index 0000000..62610f9 --- /dev/null +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Bot de Trivia para Discord", + "position": 12 +} diff --git a/i18n/es/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md b/i18n/es/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md new file mode 100644 index 0000000..b157108 --- /dev/null +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md @@ -0,0 +1,540 @@ +--- +id: trivia-bot +title: "Construye un Bot de Trivia para Discord" +sidebar_label: "Construye un Bot de Trivia para Discord" +slug: /projects/trivia-bot +description: "Construye un bot de `discord.py` que ejecuta rondas de trivia en un servidor, lleva el seguimiento de los puntos en una tabla de clasificación persistente, y puede generar preguntas nuevas sobre cualquier tema con un LLM de nivel gratuito." +--- + +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 Bot de Trivia para Discord + + + + + +Un bot `discord.py` en vivo que ejecuta rondas de trivia en un servidor: publica una pregunta, recoge respuestas dentro de un límite de tiempo, revela quién acertó, y mantiene una tabla de clasificación persistente a lo largo de las rondas. La mayoría de los bots de trivia se detienen en un banco de preguntas fijo — este añade un giro que encaja con un curso de Python: también puede generar una pregunta nueva sobre cualquier tema en el momento con un LLM de nivel gratuito, en lugar de solo preguntar siempre desde una lista preparada. + +Esto asume Python 101. No se requiere ningún otro Proyecto del Mundo Real primero, aunque si ya has construido [Construye una App de RAG](/docs/projects/rag-notes), la configuración del LLM de nivel gratuito de abajo te resultará familiar. + +Esto es opcional y no calificado. Consulta [Proyectos del mundo real](/docs/projects) para la lista completa y creciente. + +## 🎯 Lo que harás + +1. Crear una aplicación de bot de Discord y obtener su token desde el portal gratuito de desarrolladores de Discord. +2. Instalar `uv`, configurar un proyecto, y añadir `discord.py` junto con un cliente LLM de nivel gratuito. +3. Construir un banco de preguntas de trivia fijo y un comando básico de barra diagonal de Discord que publique una. +4. Añadir una tabla de clasificación persistente por jugador, almacenada entre reinicios. +5. Añadir un modo de preguntas generadas por LLM: dale un tema al bot, obtén una pregunta nueva. +6. Conectarlo todo en un bucle de ronda completo — publica una pregunta, recoge respuestas dentro de un límite de tiempo, revela la respuesta, actualiza la tabla de clasificación. +7. Invita el bot a un servidor de prueba y ejecuta rondas reales, de principio a fin. + +## Dónde ejecutar esto + +**Localmente con `uv`** es realmente la única opción práctica aquí, más que para la mayoría de los otros proyectos de esta serie. Un bot de Discord no es un script que se ejecuta una vez y termina — mantiene una conexión abierta con Discord y necesita seguir ejecutándose mientras quieras que responda a `/trivia` y recoja respuestas, lo que significa un proceso real local (o alojado) de larga duración, no un comando de una sola vez. + +**GitHub Codespaces** también funciona, y es un sustituto razonable si prefieres no instalar nada localmente: 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 `uv run python bot.py` en una terminal allí — sigue ejecutándose mientras esa terminal (y el Codespace) permanezcan abiertos, el mismo requisito de "proceso de larga duración" que ejecutarlo localmente. + +**Google Colab y Kaggle Notebooks encajan mal con el bot real** — sé honesto contigo mismo sobre eso en lugar de luchar contra ello. Los notebooks están construidos en torno a ejecutar una celda, obtener la salida, y pasar a la siguiente celda; no están pensados para un proceso en segundo plano que se sienta y espera eventos indefinidamente. *Puedes* iniciar el bucle de eventos de un bot en una celda de notebook, pero en el momento en que el runtime del notebook se recicla, se desconecta, o cierras la pestaña, el bot se cae con él — omite Colab/Kaggle para el bot en vivo y usa un proceso local real o Codespaces en su lugar. + +Dicho esto, la generación de preguntas y la puntuación *debajo* del bot son solo funciones normales que ejecutan una celda a la vez, que es exactamente para lo que los notebooks son buenos. Las insignias de abajo abren un notebook que genera preguntas LLM reales sobre algunos temas de muestra y ejecuta un par de "jugadores" falsos a través de la lógica de puntuación, para que puedas ver ambos funcionar sin instalar nada localmente. Se detiene deliberadamente antes de la capa de Discord — para eso, vuelve aquí y ejecuta `bot.py` localmente o en Codespaces como se describió arriba. + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/trivia-bot/notebook.ipynb) +[![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/trivia-bot/notebook.ipynb) + +## Configuración + +Todo en esta sección solo necesita suceder una vez, antes de que escribas una sola línea del bot en sí: instalar `uv`, crear la aplicación de bot de Discord y obtener su token, conseguir una clave LLM gratuita, y configurar el proyecto. Cada paso después de este asume que todo eso ya está hecho. + +### 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 una aplicación de bot de Discord y obtener un token + +El [Portal de Desarrolladores](https://discord.com/developers/applications) de Discord es gratuito y no necesita tarjeta: + +1. Inicia sesión y haz clic en **New Application**, dale un nombre (ej. "trivia-bot"), y créala. +2. Abre la pestaña **Bot** a la izquierda. Discord añade un usuario bot a tu aplicación automáticamente. +3. Haz clic en **Reset Token** (o **View Token** si es la primera vez) y cópialo. Este token es exactamente como una contraseña — cualquiera que lo tenga puede controlar tu bot — así que trátalo igual que tratarías una clave API de LLM: nunca lo pegues en código, nunca lo confirmes. +4. En la misma pestaña **Bot**, desplázate hasta **Privileged Gateway Intents** y activa **Message Content**. Esto es necesario para que el bot realmente lea la letra con la que responde un jugador — sin ello, `discord.py` recibe una cadena vacía para el contenido de cada mensaje sin importar el código que escribas. +5. Abre **OAuth2 → URL Generator**. Bajo **Scopes**, marca tanto `bot` como `applications.commands` (los comandos de barra diagonal necesitan específicamente el segundo); bajo **Bot Permissions**, marca al menos **Send Messages** y **Read Message History**. Mantén la URL generada a mano — la usarás en el último paso para invitar realmente el bot a un servidor. + +:::tip[Un token de bot es un secreto, exactamente como una clave API] +Nunca codifiques el token del bot, nunca lo confirmes, y mantenlo en un archivo `.env` local (abajo) en su lugar — un token de bot filtrado permite a cualquiera hacerse pasar por tu bot en cada servidor en el que está, exactamente como una clave LLM filtrada permite a cualquiera gastar tu cuota. +::: + +### Obtener una clave API de LLM gratuita + +El modo de generación de preguntas necesita una clave LLM de nivel gratuito — **elige el proveedor que prefieras**, ninguno requiere tarjeta de crédito al momento de escribir esto: + +| 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 ámbito `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. | + +El banco de preguntas fijo (Paso 1) no necesita ninguna clave LLM en absoluto — solo necesitas una una vez que llegues a la generación de preguntas por tema del Paso 3. + +### Configurar el proyecto + +```bash +uv init trivia-bot +cd trivia-bot +uv add discord.py openai python-dotenv +``` + +`discord.py` es la biblioteca que habla con Discord — conectándose a su Gateway, registrando comandos de barra diagonal, y recibiendo/enviando mensajes. `openai` habla con el endpoint compatible con OpenAI de GitHub Models para el proveedor predeterminado de arriba; cámbialo por el paquete de tu propio proveedor si elegiste uno diferente. `python-dotenv` carga secretos desde un archivo `.env` local. + +Crea un archivo `.env` en la carpeta del proyecto (nunca lo confirmes) con **ambos** secretos de esta sección: + +```bash +# .env +DISCORD_BOT_TOKEN=your-bot-token-here +GITHUB_TOKEN=your-llm-key-here +``` + +**✅ Lista de verificación** + + +Existen una aplicación de Discord y un bot en el Portal de Desarrolladores, y has copiado su token. +"Message Content" está activado bajo Privileged Gateway Intents. +Tienes una clave API de LLM de nivel gratuito de un proveedor de tu elección. +`uv init`/`uv add` se completaron sin errores, y `.env` tiene tanto `DISCORD_BOT_TOKEN` como tu clave LLM configurados. + + +**🤔 Pregunta(s) socrática(s)** + +- ¿Por qué Discord requiere que habilites explícitamente "Message Content" como una intención *privilegiada*, en lugar de darle a cada bot acceso al texto de los mensajes por defecto? +- El token del bot y la clave API del LLM son ambos secretos, pero autentican contra dos servicios completamente diferentes. ¿Qué saldría mal si accidentalmente intercambiaras qué variable de entorno contiene qué valor? + +## Paso 1: Un banco de preguntas fijo y un comando básico de barra diagonal + +Empieza con la fuente de preguntas más simple posible — una lista plana de Python de diccionarios — y suficiente cableado de Discord para publicar una: + +```python +# questions.py +"""A small fixed bank of trivia questions. Every question, from this bank +or later generated by an LLM, is the same shape: +{"question": str, "options": list[str], "answer_index": int}.""" + +import random + +QUESTION_BANK = [ + { + "question": "What year was Python first released?", + "options": ["1989", "1991", "1995", "2000"], + "answer_index": 1, + }, + { + "question": "Which planet is known as the Red Planet?", + "options": ["Venus", "Jupiter", "Mars", "Saturn"], + "answer_index": 2, + }, + # ... a handful more, see examples/trivia-bot/questions.py for the full bank +] + + +def random_question() -> dict: + return random.choice(QUESTION_BANK) +``` + +La interfaz moderna de `discord.py` para esto es un **comando de barra diagonal**: en lugar de vigilar cada mensaje buscando algo que parezca un comando, registras `/trivia` con Discord mismo, y Discord lo muestra en la interfaz con autocompletado. Eso necesita un `Client` además de un `app_commands.CommandTree` adjunto a él: + +```python +# bot.py (Step 1 version — grows through the rest of this project) +import os + +import discord +from discord import app_commands +from dotenv import load_dotenv + +from questions import random_question + +load_dotenv() + +intents = discord.Intents.default() +intents.message_content = True # requires the portal toggle from Setup, too + +client = discord.Client(intents=intents) +tree = app_commands.CommandTree(client) + + +@tree.command(name="trivia", description="Start a trivia round") +async def trivia_command(interaction: discord.Interaction) -> None: + question = random_question() + lines = [f"**{question['question']}**"] + for letter, option in zip("ABCD", question["options"]): + lines.append(f"{letter}) {option}") + await interaction.response.send_message("\n".join(lines)) + + +@client.event +async def on_ready() -> None: + await tree.sync() # registers /trivia with Discord -- can take a minute the first time + print(f"Logged in as {client.user} -- ready in {len(client.guilds)} server(s).") + + +if __name__ == "__main__": + client.run(os.environ["DISCORD_BOT_TOKEN"]) +``` + +`tree.sync()` es lo que realmente publica `/trivia` en Discord para que aparezca cuando alguien escribe `/` en tu servidor — omítelo y el comando existe en tu código pero en ningún lugar donde la interfaz de Discord pueda encontrarlo. + +:::tip[Los comandos de barra diagonal necesitan un segundo ámbito de OAuth2] +Una invitación de bot normal solo necesita el ámbito `bot`. Los comandos de barra diagonal necesitan específicamente también `applications.commands` — si generaste tu URL de invitación antes de añadir `/trivia`, regenérala con ambos ámbitos marcados (ver Configuración arriba) o el comando nunca aparecerá en silencio en tu servidor. +::: + +**✅ Lista de verificación** + + +`questions.py` define `QUESTION_BANK` y `random_question()`. +`bot.py` registra un comando de barra diagonal `/trivia` vía `app_commands.CommandTree`. +`on_ready` llama a `await tree.sync()` antes de imprimir su mensaje de listo. + + +**🤔 Pregunta(s) socrática(s)** + +- `tree.sync()` re-registra cada comando de barra diagonal con los servidores de Discord, lo que está limitado por velocidad. ¿Qué saldría mal si lo llamaras dentro de `trivia_command` en lugar de una vez en `on_ready`? +- El `answer_index` del dict de la pregunta apunta a `options` por posición en lugar de almacenar el texto de la respuesta correcta directamente. ¿Cuál es una ventaja de almacenarlo de esta manera? + +## Paso 2: Seguimiento de puntos, persistido a lo largo de las rondas + +Una tabla de clasificación solo significa algo si sobrevive al reinicio del bot, así que los puntos van a un pequeño archivo JSON en lugar de vivir solo en memoria: + +```python +# scores.py +"""Per-player score persistence in scores.json. Keyed by Discord user id +(not username), so a player's score survives a nickname change.""" + +import json +from pathlib import Path + +SCORES_PATH = Path("scores.json") + + +def load_scores() -> dict: + if not SCORES_PATH.exists(): + return {} + return json.loads(SCORES_PATH.read_text(encoding="utf-8")) + + +def save_scores(scores: dict) -> None: + SCORES_PATH.write_text(json.dumps(scores, indent=2), encoding="utf-8") + + +def award_point(scores: dict, user_id: int, display_name: str) -> dict: + key = str(user_id) + entry = scores.get(key, {"name": display_name, "score": 0}) + entry["name"] = display_name + entry["score"] += 1 + scores[key] = entry + save_scores(scores) + return scores + + +def leaderboard_text(scores: dict, top_n: int = 10) -> str: + if not scores: + return "No scores yet -- play a round with `/trivia`!" + ranked = sorted(scores.values(), key=lambda entry: entry["score"], reverse=True) + lines = [f"{i}. {entry['name']} — {entry['score']}" for i, entry in enumerate(ranked[:top_n], start=1)] + return "\n".join(lines) +``` + +Pruébalo de forma independiente antes de conectarlo a `bot.py` en absoluto — el mismo patrón de "prueba que la pieza funciona por sí sola primero" que cualquier proyecto de varias partes: + +```bash +uv run python -c " +from scores import award_point, leaderboard_text +s = {} +s = award_point(s, 111, 'Alice') +s = award_point(s, 222, 'Bob') +s = award_point(s, 111, 'Alice') +print(leaderboard_text(s)) +" +``` + +Luego añade un segundo comando de barra diagonal que solo lea el archivo: + +```python +@tree.command(name="leaderboard", description="Show the trivia leaderboard") +async def leaderboard_command(interaction: discord.Interaction) -> None: + scores = load_scores() + await interaction.response.send_message(f"**Leaderboard:**\n{leaderboard_text(scores)}") +``` + +Nada otorga un punto todavía — `trivia_command` del Paso 1 no verifica respuestas en absoluto — eso es lo que añade el bucle de ronda del Paso 4. Este paso es deliberadamente solo la mitad de almacenamiento, probada y funcionando por sí sola primero. + +**✅ Lista de verificación** + + +`scores.py` define `load_scores()`, `award_point()`, y `leaderboard_text()`. +Ejecutar la prueba independiente de `scores.py` imprime una tabla de clasificación con Alice por encima de Bob. +`/leaderboard` está registrado en `bot.py` y responde con la tabla de clasificación (aún vacía). + + +**🤔 Pregunta(s) socrática(s)** + +- Los puntos se indexan por `str(user_id)` en lugar de por el nombre mostrado del jugador. ¿Qué escenario real rompería una tabla de clasificación indexada por nombre que una indexada por ID de usuario sobrevive? +- `save_scores()` reescribe todo el archivo en cada punto individual. Para un bot pequeño de un solo servidor esto está bien — ¿en qué punto dejaría de estarlo, y qué usarías en su lugar? + +## Paso 3: Genera una pregunta nueva sobre cualquier tema con un LLM + +El banco fijo del Paso 1 solo pregunta desde el mismo puñado de preguntas. Este paso añade una segunda fuente de preguntas: dale un tema al bot, y le pide a un LLM una pregunta de opción múltiple completamente nueva sobre él, en el momento. + +```python +# generate.py +"""Generates a fresh trivia question on a topic via a free-tier LLM. +Returns the exact same shape as questions.py's bank entries, so the rest +of the bot doesn't need to know or care where a question came from.""" + +import json +import os + +from openai import OpenAI + +llm_client = OpenAI( + api_key=os.environ["GITHUB_TOKEN"], + base_url="https://models.github.ai/inference", +) + +PROMPT_TEMPLATE = """Write one multiple-choice trivia question about: {topic} + +Respond with ONLY a JSON object, no other text, in exactly this shape: +{{"question": "...", "options": ["...", "...", "...", "..."], "answer_index": 0}} + +Requirements: +- Exactly 4 options. +- Exactly one is correct; put its index (0-3) in answer_index. +- The wrong options must be plausible, not obviously silly. +- Keep the question and every option short enough to fit in a Discord message.""" + + +def generate_question(topic: str) -> dict: + response = llm_client.chat.completions.create( + model="gpt-4o-mini", # confirm this still has a free tier before running + messages=[{"role": "user", "content": PROMPT_TEMPLATE.format(topic=topic)}], + response_format={"type": "json_object"}, + ) + question = json.loads(response.choices[0].message.content) + + options = question.get("options") + answer_index = question.get("answer_index") + if not question.get("question") or not isinstance(options, list) or len(options) != 4: + raise ValueError(f"LLM returned a malformed question: {question!r}") + if not isinstance(answer_index, int) or not (0 <= answer_index < 4): + raise ValueError(f"LLM returned an invalid answer_index: {question!r}") + return question +``` + +La verificación explícita de la forma después del análisis importa: `response_format={"type": "json_object"}` garantiza que la salida del LLM sea *JSON válido*, no que sea el *JSON correcto* — aún podría devolver tres opciones en lugar de cuatro, u omitir `answer_index` por completo. Capturarlo aquí, con un error claro, es mejor que descubrirlo más tarde como un mensaje confuso de Discord con una opción D que falta. + +Conecta un parámetro `topic` en `/trivia` para que pueda extraer de cualquiera de las dos fuentes: + +```python +from round import pick_question # combines random_question() and generate_question() +``` + +```python +# round.py +"""Non-Discord round logic shared by bot.py and the notebook.""" + +from generate import generate_question +from questions import random_question + + +def pick_question(topic: str | None = None) -> dict: + if topic: + return generate_question(topic) + return random_question() +``` + +```python +@tree.command(name="trivia", description="Start a trivia round, optionally on a topic") +@app_commands.describe(topic="Optional topic for a freshly generated question") +async def trivia_command(interaction: discord.Interaction, topic: str | None = None) -> None: + question = pick_question(topic) + ... +``` + +Prueba ambos caminos desde una terminal antes de confiar en ellos dentro de Discord: + +```bash +uv run python -c "from round import pick_question; print(pick_question())" +uv run python -c "from round import pick_question; print(pick_question('classic video games'))" +``` + +:::tip[Valida el contenido generado por LLM antes de que llegue a un canal en vivo] +Un LLM al que se le pide una pregunta de trivia aún puede equivocarse en los hechos, especialmente en temas oscuros — no hay `try`/`except` que capture "equivocado con confianza". La validación de la forma en `generate_question()` solo protege contra una *estructura* malformada; para un servidor público, hojea un puñado de preguntas generadas sobre temas que realmente conozcas antes de confiar en el modo en temas que no conoces. +::: + +**✅ Lista de verificación** + + +`generate_question(topic)` de `generate.py` devuelve un dict con 4 opciones y un `answer_index` válido, o lanza un error claro. +`pick_question()` de `round.py` devuelve una pregunta del banco cuando `topic` está vacío, y una generada en caso contrario. +`/trivia` acepta un argumento `topic` opcional y lo usa visiblemente. + + +**🤔 Pregunta(s) socrática(s)** + +- `generate_question()` valida que `answer_index` sea un int en `0..3` y que haya exactamente 4 opciones, pero no valida que el *contenido* sea realmente trivia correcta. ¿Dónde está la línea entre lo que el código puede verificar razonablemente y lo que solo un humano que revise la salida puede hacer? +- Si un jugador elige un tema intencionalmente ofensivo o sin sentido, ¿cuál es lo peor plausible que `generate_question()` podría devolver, y qué añadirías para protegerte contra ello? + +## Paso 4: Un bucle de ronda de trivia completo + +Todo hasta ahora han sido piezas probadas de forma aislada: una fuente de preguntas, almacenamiento de puntos, generación. Este paso las conecta en lo que una ronda realmente parece en vivo — publica una pregunta, espera la primera respuesta correcta dentro de un límite de tiempo, revélala, actualiza la tabla de clasificación: + +```python +# bot.py (relevant part -- see examples/trivia-bot/bot.py for the full file) +import asyncio + +from round import OPTION_LETTERS, check_answer, format_question, pick_question +from scores import award_point, leaderboard_text, load_scores + +ROUND_TIME_LIMIT = 30 # seconds + + +async def run_round(channel: discord.abc.Messageable, topic: str | None = None) -> None: + question = pick_question(topic) + valid_letters = OPTION_LETTERS[: len(question["options"])] + await channel.send( + f"{format_question(question)}\n\nYou have {ROUND_TIME_LIMIT}s -- " + f"reply with just the letter ({'/'.join(valid_letters)})." + ) + + def is_candidate_answer(message: discord.Message) -> bool: + return ( + message.channel == channel + and not message.author.bot + and message.content.strip().upper() in valid_letters + ) + + loop = asyncio.get_event_loop() + deadline = loop.time() + ROUND_TIME_LIMIT + winner = None + + while True: + remaining = deadline - loop.time() + if remaining <= 0: + break + try: + message = await client.wait_for("message", check=is_candidate_answer, timeout=remaining) + except asyncio.TimeoutError: + break + if check_answer(question, message.content): + winner = message.author + break + await message.add_reaction("❌") + + correct_letter = OPTION_LETTERS[question["answer_index"]] + correct_text = question["options"][question["answer_index"]] + + if winner is not None: + scores = award_point(load_scores(), winner.id, str(winner.display_name)) + await channel.send( + f"✅ {winner.mention} got it! The answer was **{correct_letter}) {correct_text}**.\n\n" + f"**Leaderboard:**\n{leaderboard_text(scores)}" + ) + else: + await channel.send(f"⏰ Time's up! Nobody got it. The answer was **{correct_letter}) {correct_text}**.") +``` + +`client.wait_for("message", check=..., timeout=...)` es la forma de `discord.py` de pausar una función `async` hasta que ocurra un tipo específico de evento — aquí, cualquier mensaje en el mismo canal cuyo contenido sea exactamente una de las letras de respuesta válidas. El bucle `while` lo vuelve a llamar con un timeout `remaining` decreciente, de modo que el presupuesto de tiempo *total* de la ronda sea `ROUND_TIME_LIMIT`, no `ROUND_TIME_LIMIT` por suposición incorrecta — sin recalcular `remaining`, un canal lleno de suposiciones incorrectas entusiastas podría mantener la ronda abierta indefinidamente. + +Solo la *primera* respuesta correcta puntúa; haz `break` tan pronto como se establezca `winner`. Las suposiciones incorrectas obtienen una reacción ❌ en lugar de un mensaje de error — retroalimentación gratuita sin saturar el canal con respuestas. + +Finalmente, `trivia_command` del Paso 1 se convierte en un envoltorio delgado alrededor de `run_round`: + +```python +@tree.command(name="trivia", description="Start a trivia round, optionally on a topic") +@app_commands.describe(topic="Optional topic for a freshly generated question") +async def trivia_command(interaction: discord.Interaction, topic: str | None = None) -> None: + starting_text = f"🎲 Starting a round about **{topic}**..." if topic else "🎲 Starting a round..." + await interaction.response.send_message(starting_text) + try: + await run_round(interaction.channel, topic) + except Exception as error: # keep the bot alive even if one round fails + print(f"Error running trivia round: {error!r}") + await interaction.channel.send("Something went wrong running that round -- see the bot's console log.") +``` + +:::tip[Prueba el tiempo de la ronda con un ROUND_TIME_LIMIT corto primero] +Establece `ROUND_TIME_LIMIT = 5` mientras ajustas el bucle, para no esperar 30 segundos por ciclo de prueba para descubrir que `check_answer` tiene un error. Súbelo de nuevo a algo razonable para el juego real una vez que el bucle en sí funcione. +::: + +**✅ Lista de verificación** + + +`/trivia` publica una pregunta y luego realmente espera una respuesta en lugar de resolverse al instante. +La primera respuesta correcta dentro del límite de tiempo se anuncia como ganadora y recibe un punto vía `award_point()`. +Dejar que el temporizador se agote sin respuesta correcta revela la respuesta sin bloquearse ni colgarse. +Ejecutar `/trivia` dos veces seguidas inicia una ronda nueva cada vez, usando la tabla de clasificación actualizada. + + +**🤔 Pregunta(s) socrática(s)** + +- `is_candidate_answer` verifica `message.channel == channel` para que las respuestas de otros canales del servidor no cuenten. ¿Qué le pasaría a una ronda en un servidor ocupado si esa verificación faltara? +- El `try`/`except Exception` alrededor de `run_round(...)` captura *cualquier* excepción y publica un error genérico en lugar de bloquearse. ¿Cuál es el compromiso de capturar tan ampliamente en un bot de larga duración frente a dejar que un error real bloquee el proceso ruidosamente? + +## Invita el bot y juega una ronda real + +Usando la URL de OAuth2 que generaste en Configuración (con ambos ámbitos `bot` y `applications.commands`), ábrela en un navegador y elige un servidor que controles — crea un servidor de prueba gratuito si no tienes uno todavía. + +```bash +uv run python bot.py +``` + +Deberías ver impreso `Logged in as trivia-bot#1234 -- ready in 1 server(s).`. En el servidor de prueba, escribe `/trivia` y elígelo del menú de autocompletado de Discord — con o sin `topic`. En unos segundos deberías ver la pregunta publicada, y después de responder correctamente (o dejar que el temporizador se agote) la respuesta revelada y la tabla de clasificación actualizada. Ejecuta `/leaderboard` en cualquier momento para verificar los puntos sin iniciar una ronda nueva. + +## ⚠️ Errores comunes + +- **Olvidar la intención privilegiada "Message Content".** Esto tiene que estar habilitado en *dos* lugares — `intents.message_content = True` en el código, **y** el interruptor bajo Bot → Privileged Gateway Intents en el Portal de Desarrolladores. Omite el interruptor del portal y `message.content` es silenciosamente una cadena vacía para cada mensaje, así que `is_candidate_answer` nunca coincide con ninguna respuesta sin importar cómo se escriba. +- **Confundir el token del bot con el secreto de cliente de OAuth2.** El Portal de Desarrolladores muestra ambos en pestañas diferentes. El token del bot (pestaña Bot) es lo que necesita `client.run(...)`; el secreto de cliente (pestaña OAuth2) es para un flujo de autenticación completamente diferente que este proyecto nunca usa. Pegar el secreto de cliente en `DISCORD_BOT_TOKEN` falla al iniciar sesión con un error confuso. +- **`/trivia` nunca aparece en la interfaz de Discord.** Usualmente una de dos causas: `tree.sync()` nunca se llamó (o no se esperó) en `on_ready`, o la URL de invitación del bot se generó antes de añadir el ámbito `applications.commands`. Regenera la URL de invitación con ambos ámbitos y re-invita al bot si el segundo es el problema. +- **Límites de velocidad en el nivel gratuito del LLM, peores con varias rondas seguidas.** Cada llamada `/trivia ` es una solicitud LLM separada contra la cuota de nivel gratuito de tu proveedor, y un servidor ocupado que ejecuta varias rondas consecutivas puede alcanzarla más rápido de lo que esperarías solo de las pruebas. Un error 429 no es un bug — añade un reintento corto con retroceso alrededor de `generate_question()`, o recurre al banco fijo cuando la generación falle. +- **Una ronda que nunca termina porque `remaining` no se recalcula.** Si copias el bucle de ronda pero llamas a `client.wait_for(..., timeout=ROUND_TIME_LIMIT)` (la constante fija) en lugar del valor decreciente `remaining`, cada suposición incorrecta efectivamente reinicia el reloj — la ronda puede durar mucho más de lo que `ROUND_TIME_LIMIT` realmente promete. + +## Lo que acabas de construir + +Un bot de trivia de Discord en vivo con dos fuentes de preguntas — un banco fijo y generación por LLM de nivel gratuito sobre cualquier tema — un bucle de ronda completo con tiempo real, y una tabla de clasificación persistente por jugador que sobrevive a los reinicios. La fuente de preguntas, la puntuación, y la lógica de ronda (`questions.py`, `generate.py`, `scores.py`, `round.py`) son todo Python simple sin `discord`, probados de forma independiente antes de tocar cualquier canal en vivo; solo `bot.py` sabe que Discord existe en absoluto. Esa división vale la pena tenerla en cuenta en general: los mismos cuatro módulos podrían estar detrás de un bot de Slack, un formulario web, o un juego de CLI en su lugar, sin cambios en ninguno de ellos. + +## A dónde ir desde aquí + +- Añade un **modo de juego de múltiples rondas** — `/trivia rounds:5` que juega varias preguntas consecutivas y anuncia un ganador general al final, en lugar de una pregunta por comando. +- Rastrea **etiquetas de dificultad o categoría** en las preguntas generadas (pide al LLM que incluya una en su respuesta JSON) y deja que los jugadores elijan una categoría con `/trivia topic:... difficulty:hard`. +- Añade una **tabla de clasificación por servidor** en lugar de un `scores.json` global — indexa `scores.json` por `(guild_id, user_id)` en lugar de solo `user_id`, para que dos servidores diferentes de Discord que ejecuten este bot no compartan una tabla de clasificación. +- Despliega el bot en algún lugar que permanezca activo sin tu portátil encendido — una VM pequeña siempre activa, o un nivel gratuito en una plataforma como Railway o Fly.io — para que siga alojando noches de trivia incluso cuando no estás en tu máquina. + +## 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 recorrido 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/es/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md b/i18n/es/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md new file mode 100644 index 0000000..375c764 --- /dev/null +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md @@ -0,0 +1,302 @@ +--- +id: 2027-webcam-object-counter +title: "Cuenta Objetos en Tiempo Real con una Cámara Web" +sidebar_label: "Contador de Objetos con Cámara Web" +slug: /projects/webcam-object-counter +description: "Cuenta objetos en vivo desde el feed de una cámara web con OpenCV y un modelo YOLO11n preentrenado — o ejecuta la misma detección sobre una imagen o video de muestra incluido sin ninguna cámara." +--- + +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'; + +# 🌍 Cuenta Objetos en Tiempo Real con una Cámara Web + + + + + +Este proyecto asume que te sientes cómodo con Python 101 — funciones, bucles e instalación de paquetes — y no necesita ningún conocimiento previo de análisis de datos o aprendizaje automático. Es la primera incursión de este curso en la visión por computadora: en lugar de cargar un modelo preentrenado que lee texto o filas tabulares, cargarás uno que lee píxeles, y lo usarás para responder una pregunta genuinamente práctica en tiempo real — "¿cuántos de *esto* hay frente a la cámara ahora mismo?" + +Esto es opcional y no calificado. Consulta [Proyectos del mundo real](/docs/projects) para la lista completa y creciente. + +## 🎯 Lo que harás + +1. Instalar `uv` y configurar un proyecto local con OpenCV y un modelo de detección de objetos preentrenado. +2. Ejecutar la detección sobre una sola imagen de muestra incluida y dibujar cuadros delimitadores alrededor de lo que encuentra. +3. Contar objetos de una clase objetivo (p. ej. `person`) e imprimir un total acumulado. +4. Procesar un breve video de muestra incluido cuadro por cuadro. +5. Conectar el mismo bucle de detección a tu propia cámara web para conteo en vivo y en tiempo real. + +## Dónde ejecutar esto + +**Localmente con `uv` es la única manera de obtener la experiencia completa de cámara web en vivo.** Una cámara web física conectada a tu computadora es hardware — no hay ruta desde una pestaña del navegador corriendo en la nube hasta una cámara sentada en tu escritorio. Los pasos 1–5 de abajo asumen este camino, y el Paso 5 específicamente simplemente no funcionará en ningún otro lugar. + +- **GitHub Codespaces** te da un entorno de desarrollo en la nube de configuración cero (Node, Python y `uv` ya instalados — ver [`.devcontainer/devcontainer.json`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/.devcontainer/devcontainer.json)), y los Pasos 1–4 (imagen de muestra, conteo, video de muestra) funcionan bien ahí. El Paso 5 no — un Codespace corre en un servidor remoto sin acceso a tu cámara web local tampoco. +- **Google Colab, Kaggle Notebooks o Binder** son buenos para la variante **solo-imagen-de-muestra** de este proyecto, no la cámara web en vivo. Un notebook real y ejecutable que descarga las imágenes de muestra incluidas y ejecuta el mismo código de detección vive en [`examples/webcam-object-counter/notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/webcam-object-counter/notebook.ipynb) (apuntará a `main` una vez fusionado). Haz clic en una insignia para lanzarlo directamente: + + [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/webcam-object-counter/notebook.ipynb) + [![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/webcam-object-counter/notebook.ipynb) + [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fwebcam-object-counter%2Fnotebook.ipynb) + + Sé honesto contigo mismo sobre lo que esto te da: solo detección de imágenes de muestra, no un feed de cámara en vivo. Es una forma genuinamente buena de ver el modelo funcionar con cero instalación, pero no es el mismo proyecto que el Paso 5. + +## Configuración + +`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 +``` + +Luego configura el proyecto: + +```bash +uv init webcam-object-counter +cd webcam-object-counter +uv add opencv-python ultralytics +``` + +No se necesita clave de API en ningún lugar de este proyecto — la detección corre completamente en local, sin servicio externo involucrado. Sé consciente del tamaño, sin embargo: `opencv-python` y `ultralytics` (que arrastra PyTorch) son una descarga real — espera que este `uv add` tome unos minutos y unos cientos de megabytes de espacio en disco la primera vez. + +:::tip[Dos maneras de detectar objetos — elige la que encaje] +OpenCV trae integradas las **cascadas de Haar** — pequeñas, rápidas, sin descarga adicional, pero limitadas: cada cascada está entrenada para una cosa específica (el ejemplo clásico es `haarcascade_frontalface_default.xml` para rostros de frente) y funciona mejor en una vista frontal bastante limpia. Este proyecto en cambio usa **YOLO11n** a través del paquete `ultralytics` — un modelo de detección de objetos pequeño (unos pocos megabytes) pero genuinamente moderno, preentrenado sobre las 80 clases de objetos cotidianos del conjunto de datos COCO (persona, auto, perro, autobús, silla y más), que reconoce mucho más que rostros y maneja escenas del mundo real más desordenadas mucho mejor. La compensación honesta: YOLO11n es una instalación más grande y un poco más lento por cuadro que una cascada de Haar, pero detecta objetos reales, no solo rostros, que es todo el punto de un proyecto de "contar objetos" de propósito general. Si solo necesitas detectar rostros, una cascada de Haar es una alternativa perfectamente razonable y más ligera que vale la pena conocer. +::: + +## Paso 1: Detecta objetos en una sola imagen de muestra + +Cada script de abajo reutiliza esta misma idea central. `yolo11n.pt` es un punto de control preentrenado — `ultralytics` lo descarga automáticamente la primera vez que construyes `YOLO(...)`, y lo almacena en caché localmente después: + +```python +# detect_image.py +import cv2 +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") + +results = model("samples/street.jpg") +result = results[0] + +print(f"Detected {len(result.boxes)} object(s):") +for box in result.boxes: + class_name = model.names[int(box.cls)] + confidence = float(box.conf) + print(f" - {class_name} ({confidence:.0%} confidence)") + +annotated = result.plot() # draws boxes + labels on a copy of the image +cv2.imwrite("output_street.jpg", annotated) +``` + +```bash +uv run python detect_image.py +``` + +`model(image_path)` ejecuta todo el pipeline de detección en una sola llamada: redimensiona la imagen, la pasa por la red, y convierte la salida cruda en una lista de cuadros, cada uno con una etiqueta de clase y un puntaje de confianza. `result.boxes` es esa lista — `box.cls` es un índice de clase dentro de `model.names` (un dict de los 80 nombres de clases COCO), y `box.conf` es la confianza del modelo de que el cuadro realmente contiene esa clase. `result.plot()` es un método de conveniencia que dibuja todo eso de vuelta en la imagen por ti, para que no tengas que escribir tu propio bucle de dibujo de cuadros con `cv2.rectangle`. + +**✅ Lista de verificación** + + +Ejecutar el script imprime al menos un objeto detectado con un nombre de clase y un puntaje de confianza. +`output_street.jpg` existe y, abierto en un visor de imágenes, muestra cuadros dibujados alrededor de objetos reales en la imagen. +Puedes explicar, en una oración, qué representan cada uno de `box.cls` y `box.conf`. + + +**🤔 Pregunta(s) socrática(s)** + +El modelo devuelve un puntaje de confianza para cada cuadro, no solo un sí/no de "objeto aquí". Si filtraras cualquier cuadro con confianza por debajo del 90%, ¿esperarías ver más detecciones falsas o más detecciones perdidas — y cuál de esos dos errores importa más para un proyecto cuyo punto central es un *conteo* preciso? + +## Paso 2: Cuenta una clase objetivo y mantén un total acumulado + +Detectarlo todo es un buen comienzo, pero "contar objetos" usualmente significa contar *un tipo* de cosa — personas caminando por una puerta, autos en un estacionamiento, y así: + +```python +# count_class.py +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") + +target_class = "person" +image_paths = ["samples/street.jpg", "samples/people.jpg"] + +running_total = 0 +for image_path in image_paths: + result = model(image_path, verbose=False)[0] + count = sum(1 for box in result.boxes if model.names[int(box.cls)] == target_class) + running_total += count + print(f"{image_path}: {count} {target_class}(s) -- running total: {running_total}") + +print(f"\nTotal {target_class}(s): {running_total}") +``` + +```bash +uv run python count_class.py +``` + +El conteo es solo un filtro-y-suma sobre `result.boxes`, comparando el nombre de clase de cada cuadro contra el que te importa. `verbose=False` silencia el registro por llamada de `ultralytics` para que tus propias declaraciones `print` no queden enterradas debajo. + +**✅ Lista de verificación** + + +El script imprime un conteo por imagen y un total acumulado que solo sube. +Cambiar `target_class` a una clase COCO diferente (p. ej. `"bus"`) cambia los conteos impresos en consecuencia. +Entiendes por qué esto reutiliza `model.names[int(box.cls)]` en lugar de codificar un número de índice de clase. + + +**🤔 Pregunta(s) socrática(s)** + +Si dos personas en una foto están paradas tan juntas que sus cuadros delimitadores casi se superponen por completo, ¿hay alguna manera realista de que este enfoque de conteo las cuente de menos o de más? ¿Qué mirarías en `result.boxes` para verificar? + +## Paso 3: Procesa un breve video de muestra cuadro por cuadro + +Un video es solo una secuencia de imágenes — el mismo código de detección por imagen de los Pasos 1–2, ejecutado una vez por cuadro en un bucle: + +```python +# detect_video.py +import cv2 +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") +target_class = "person" + +cap = cv2.VideoCapture("samples/sample_street.mp4") +fps = cap.get(cv2.CAP_PROP_FPS) or 15 +width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) +height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) +writer = cv2.VideoWriter("output_video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)) + +while True: + ok, frame = cap.read() + if not ok: + break # end of the video file, not a broken camera + + result = model(frame, verbose=False)[0] + count = sum(1 for box in result.boxes if model.names[int(box.cls)] == target_class) + + annotated = result.plot() + cv2.putText(annotated, f"{target_class}s: {count}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) + writer.write(annotated) + +cap.release() +writer.release() +``` + +```bash +uv run python detect_video.py +``` + +`cv2.VideoCapture` lee un archivo de video (o, en el Paso 4, una cámara en vivo) un cuadro a la vez vía `.read()`, que devuelve `(ok, frame)` — `ok` es `False` una vez que no hay más cuadros. `cv2.VideoWriter` es la misma idea a la inversa: acumula los cuadros que le das en un nuevo archivo de video. Nota que el `if not ok: break` aquí significa "el archivo terminó" — el Paso 4 reutiliza exactamente esta misma verificación, pero allí significa algo importante y diferente. + +**✅ Lista de verificación** + + +`output_video.mp4` existe y se reproduce, mostrando cuadros delimitadores y un conteo en vivo superpuesto en cada cuadro. +Puedes explicar qué devuelve `cap.read()` y por qué el bucle verifica `ok` antes de usar `frame`. +Has notado que el conteo puede parpadear entre cuadros incluso cuando nada en la escena cambió visiblemente. + + +**🤔 Pregunta(s) socrática(s)** + +El conteo que imprimes es una instantánea por cuadro, no un total por video — pasar a la misma persona frente a la cámara durante tres segundos podría contarla en cada cuadro. ¿Qué requeriría "contar cuántas personas *distintas* cruzaron el cuadro", más allá de lo que este script hace actualmente? + +## Paso 4: Ve en vivo con tu cámara web + +Mismo bucle, una línea diferente: cambia la ruta del archivo de video por `0`, el índice de la cámara predeterminada de tu computadora: + +```python +# detect_webcam.py +import cv2 +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") +target_class = "person" + +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Could not open the webcam. Check that one is connected, that no other " + "app is using it, and that this program has camera permission.") +else: + print("Webcam opened. Press 'q' in the video window to quit.") + while True: + ok, frame = cap.read() + if not ok: + print("Lost the camera feed. Stopping.") + break + + result = model(frame, verbose=False)[0] + count = sum(1 for box in result.boxes if model.names[int(box.cls)] == target_class) + + annotated = result.plot() + cv2.putText(annotated, f"{target_class}s: {count}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) + cv2.imshow("Webcam Object Counter (press q to quit)", annotated) + + if cv2.waitKey(1) & 0xFF == ord("q"): + break + + cap.release() + cv2.destroyAllWindows() +``` + +```bash +uv run python detect_webcam.py +``` + +`cv2.VideoCapture(0)` abre tu cámara predeterminada de la misma manera que `VideoCapture("some_file.mp4")` abrió un archivo en el Paso 3 — el mismo bucle `.read()`, la misma forma `(ok, frame)`. Las dos diferencias importantes: `.isOpened()` se verifica *de antemano* aquí, ya que "sin cámara web disponible" es un fallo real y común que debería producir un mensaje claro en lugar de un colapso confuso en el fondo del bucle; y una vez corriendo, que `ok` se vuelva `False` a mitad del bucle significa que la conexión de la cámara se perdió (desconectada, permiso revocado), no "se llegó al final", ya que una cámara en vivo no tiene final. `cv2.imshow` abre una ventana en vivo — una ventana GUI real, así que este script no producirá salida visible en una terminal remota simple sin pantalla. + +**✅ Lista de verificación** + + +Se abre una ventana mostrando tu feed de cámara en vivo con cuadros delimitadores y un conteo en ejecución dibujados en él. +Sostener un número diferente del objeto objetivo (p. ej. tú mismo, luego tú y una segunda persona) cambia el conteo impreso/en pantalla en consecuencia. +Desconectar o cubrir la cámara a mitad de ejecución produce el mensaje de "se perdió el feed de la cámara", no un cuelgue silencioso. +Presionar "q" cierra la ventana limpiamente en lugar de necesitar un forzar-salida. + + +**🤔 Pregunta(s) socrática(s)** + +Los Pasos 3 y 4 usan `if not ok: break` en exactamente el mismo lugar del código, pero esa línea significa algo diferente en cada uno ("fin de archivo" vs. "problema de cámara"). ¿Por qué vale la pena escribir un mensaje distinto para cada caso en código real, en lugar de tratar ambos como el mismo error genérico? + +## ⚠️ Errores comunes + +- **Permiso de cámara web denegado.** macOS y Windows piden acceso a la cámara la primera vez que una app intenta usarla — si descartaste ese aviso (o apareció detrás de otra ventana), `cv2.VideoCapture(0).isOpened()` devolverá `False` incluso con una cámara perfectamente funcional. Revisa la configuración de privacidad de la cámara de tu sistema operativo para tu app de terminal o intérprete de Python específicamente. +- **La primera ejecución es lenta y necesita una conexión a internet.** `ultralytics` descarga `yolo11n.pt` desde los servidores de Ultralytics la primera vez que construyes `YOLO(...)` — después se almacena en caché localmente (típicamente bajo `~/.cache` o el directorio actual) y cada ejecución posterior es completamente offline. Si la primera ejecución parece colgarse, probablemente todavía está descargando, no atascada. +- **Confundir "ningún objeto detectado" con "la cámara no funciona".** Estos se ven idénticos a primera vista — un conteo vacío de cualquier manera — pero tienen soluciones completamente diferentes. Verifica `cap.isOpened()` y si `cv2.imshow` muestra una imagen en vivo en absoluto *antes* de preocuparte por qué el conteo es cero; un feed funcional con un conteo genuinamente vacío (nada que coincida con tu clase objetivo está en el cuadro) no es un bug. +- **Desajustes de índice de cámara en máquinas con más de una cámara.** `VideoCapture(0)` abre la cámara que tu sistema operativo considera la predeterminada, que no siempre es la que esperas en una laptop con una cámara web externa conectada — prueba `1`, `2`, etc. si `0` abre la equivocada. + +## Lo que acabas de construir + +Un pipeline de visión por computadora real y funcional: carga un modelo preentrenado, ejecútalo sobre píxeles en lugar de filas o texto, y convierte su salida cruda (cuadros, índices de clase, puntajes de confianza) en algo que una persona realmente quiere — un conteo en vivo de un tipo específico de objeto. La misma forma de tres pasos (detección por imagen → filtrar a una clase → bucear sobre cuadros) escala desde una sola foto hasta un feed de cámara genuinamente en vivo con solo el origen de entrada cambiando. + +:::tip[Esto se generaliza más allá de "contar objetos"] +Todo aquí — un detector preentrenado, un bucle sobre cuadros, un conteo en ejecución — es también la columna vertebral de cosas como sensores de conteo de personas en entradas de tiendas, cámaras básicas de conteo de tráfico, y contadores de especies en cámaras trampa de vida silvestre. La lógica de conteo en el Paso 2 es deliberadamente simple (sin seguimiento de objetos entre cuadros, así que una persona parada quieta durante diez cuadros se cuenta en los diez), que es una simplificación honesta, no un bug oculto — ver la sección "A dónde ir desde aquí" para lo que los sistemas reales añaden encima. +::: + +## A dónde ir desde aquí + +- **Seguimiento de objetos, no solo detección.** La pregunta socrática del Paso 3 apunta al vacío real: este proyecto cuenta objetos *por cuadro*, no objetos distintos *a través* de un video. Librerías como el modo de seguimiento integrado del propio `ultralytics` (`model.track(...)`, usando algoritmos como ByteTrack) asignan un ID persistente a cada objeto a través de los cuadros, así que "cuántas personas *distintas* cruzaron el cuadro" se vuelve respondible en lugar de solo "cuántas están en el cuadro ahora mismo". +- **Un modelo más grande y más preciso.** `yolo11n.pt` ("n" de nano) intercambia algo de precisión por velocidad y tamaño. `ultralytics` trae puntos de control más grandes (`yolo11s.pt`, `yolo11m.pt` y más) que detectan más confiablemente, especialmente en objetos pequeños o parcialmente ocultos, al costo de necesitar más cómputo por cuadro — vale la pena probarlos si los conteos en vivo del Paso 4 se sienten poco confiables en tu configuración particular. +- **Una clase personalizada, no solo las 80 de COCO.** YOLO11n solo reconoce lo que fue entrenado para reconocer. El ajuste fino de un modelo YOLO en tus propias imágenes etiquetadas (una versión mucho más pequeña de la misma idea que el [proyecto Fine-tune a Small Language Model](/docs/projects/finetune-llm-unsloth)) te permite contar algo que COCO nunca incluyó — un producto específico en un estante, una herramienta específica, cualquier cosa de la que puedas etiquetar unos cientos de ejemplos. + +## 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 recorrido completo y amigable para principiantes sobre cómo agregar 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/es/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md b/i18n/es/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md new file mode 100644 index 0000000..d69cc79 --- /dev/null +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md @@ -0,0 +1,332 @@ +--- +id: wordle-clone +title: "Construye un Clon de Wordle" +sidebar_label: "Clon de Wordle" +slug: /projects/wordle-clone +description: "Construye un juego de Wordle real de terminal desde cero: retroalimentación correcta de verde/amarillo/gris por intento (incluyendo el clásico error de letras repetidas), una lista de palabras personalizada, y seguimiento de estadísticas persistente entre sesiones." +--- + +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 Clon de Wordle + + + + + +Este proyecto solo asume lo básico a nivel de Python 101 — funciones, listas, diccionarios, bucles, leer y escribir un archivo. Sin pandas, sin clave de API, sin GPU, sin servicio externo de ningún tipo — solo una terminal, una lista de palabras, y algo de lógica que es más difícil de hacer bien de lo que parece. Eso lo convierte en un excelente Proyecto del Mundo Real *más temprano* para intentar, incluso antes de algunos de los orientados a pandas o a IA: todo lo que necesitas es material que Python 101 ya te dio, aplicado a algo genuinamente divertido de jugar después. + +Esto es opcional y no calificado. Consulta [Proyectos del mundo real](/docs/projects) para la lista completa y creciente. + +## 🎯 Lo que harás + +1. Implementar la lógica central de retroalimentación de intentos — comparar un intento con la palabra objetivo y producir marcas verdes/amarillas/grises por letra, manejando correctamente las letras repetidas (el clásico error de lógica de Wordle). +2. Construir un bucle de juego interactivo respaldado por una lista de palabras real, dándole al jugador 6 intentos. +3. Validar los intentos contra la lista de palabras y dar retroalimentación clara cuando un intento es rechazado. +4. Añadir seguimiento de estadísticas persistente — tasa de victorias, racha actual, y una distribución de número de intentos — guardado en un archivo JSON local para que sobreviva entre ejecuciones. + +## Dónde ejecutar esto + +- **Localmente con `uv` (recomendado).** Este proyecto no necesita nada más allá de la biblioteca estándar más una pequeña biblioteca de colores de terminal — un buen candidato para instalar Python de verdad en tu propia máquina. La sección Configuración de abajo lo explica paso a paso, y los Pasos 1–4 siguen este camino. +- **GitHub Codespaces.** Abre [codespaces.new/abderrahim-lectures/python-data-analysis-course](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) para un entorno de desarrollo en la nube con Node, Python y `uv` ya instalados (consulta [`.devcontainer/devcontainer.json`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/.devcontainer/devcontainer.json)) — los mismos comandos de abajo funcionan desde una pestaña del navegador, sin instalación local en absoluto. +- **Google Colab, Kaggle Notebooks, o Binder.** Este proyecto necesita cero dependencias externas, lo que lo hace un ajuste excelente para notebook en un sentido — pero el prompt `input()` de un notebook es un poco diferente de una terminal interactiva real: sin tiles de colores redibujados en su lugar en una sola línea, y (en Colab/Kaggle) los archivos locales de una sesión no sobreviven de forma fiable entre visitas separadas, lo que va en contra de la parte "las estadísticas persisten entre sesiones" de este proyecto. [`notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/wordle-clone/notebook.ipynb) sigue siendo una versión real y jugable — vale la pena probarla — solo ten en cuenta que la experiencia completa (tiles de colores en la terminal, estadísticas que persisten entre días separados de juego) es realmente algo de "ejecútalo localmente". + + [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/wordle-clone/notebook.ipynb) + [![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/wordle-clone/notebook.ipynb) + [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fwordle-clone%2Fnotebook.ipynb) + + {/* Badges point at this PR's branch; will point at `main` once merged. */} + +## Configuración + +`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 +``` + +Luego configura el proyecto: + +```bash +uv init wordle-clone +cd wordle-clone +uv add rich +``` + +`rich` es la única dependencia de terceros que necesita todo este proyecto, y se usa puramente para la salida de colores en la terminal (tiles verdes/amarillos/grises) — cada parte de la lógica de juego real de abajo es Python de biblioteca estándar puro. Sin clave de API, sin registro, nada que configurar antes de poder ejecutar una sola línea de código. + +## Paso 1: Puntúa un intento contra la palabra objetivo + +Empieza con la pieza que es fácil de dejar *casi* bien y satisfactoria de dejar *realmente* bien: dado un intento de 5 letras y una palabra objetivo de 5 letras, produce una marca por letra — verde si esa letra está en la posición correcta, amarilla si está en la palabra pero en la posición equivocada, gris en caso contrario. + +Un primer intento tiende a verse así, revisando cada letra adivinada de forma independiente: + +```python +# A tempting first version — has a bug, keep reading +def score_guess_naive(guess: str, target: str) -> list[str]: + marks = [] + for i, letter in enumerate(guess): + if letter == target[i]: + marks.append("G") + elif letter in target: + marks.append("Y") + else: + marks.append("X") + return marks +``` + +Pruébalo con `guess = "SPEED"`, `target = "ERASE"`. La palabra objetivo tiene exactamente **una** `E`. La versión ingenua revisa cada letra adivinada contra la cadena objetivo completa de forma independiente — así que *ambas* `E`s en `SPEED` se revisan contra `"E" in target`, que es `True` las dos veces, y ambas se marcan amarillas. Eso está mal: el Wordle real nunca otorgaría dos `E`s amarillas en un intento cuando la palabra objetivo solo contiene una `E` — una `E` adivinada merece una marca, la otra no tiene una letra coincidente restante que justifique una. + +La solución es un algoritmo de dos pasadas: + +```python +from collections import Counter + +WORD_LENGTH = 5 + +def score_guess(guess: str, target: str) -> list[str]: + guess, target = guess.upper(), target.upper() + marks = ["X"] * WORD_LENGTH + + # Pass 1: greens, and tally which target letters are still "available" + # (i.e. not already accounted for by a green) for the yellow pass. + remaining = Counter() + for i, (g, t) in enumerate(zip(guess, target)): + if g == t: + marks[i] = "G" + else: + remaining[t] += 1 + + # Pass 2: yellows, consuming from that same pool of remaining letters + # so a letter can never be flagged more times than it truly occurs. + for i, g in enumerate(guess): + if marks[i] == "G": + continue + if remaining[g] > 0: + marks[i] = "Y" + remaining[g] -= 1 + # else stays "X" + + return marks +``` + +La Pasada 1 marca cada coincidencia de posición exacta en verde, y por separado cuenta (en `remaining`) cuántas copias de cada letra del objetivo *no verde* siguen "en juego". La Pasada 2 luego recorre el intento de nuevo: cualquier letra que no esté ya en verde solo recibe una marca amarilla si `remaining` todavía tiene una copia sin reclamar de ella — y reclamar una decrementa el conteo, así que una segunda copia adivinada de la misma letra no recibirá también un amarillo a menos que el objetivo genuinamente tenga una segunda copia también. + +Ejecútalo en el caso delicado: + +```python +print(score_guess("SPEED", "ERASE")) # ['Y', 'X', 'Y', 'Y', 'X'] +``` + +Una `E` (posición 0) es amarilla, la otra (posición 3) también es amarilla porque `ERASE` realmente tiene dos `E`s — pero un intento como `"ELITE"` contra una palabra objetivo con solo una `E` le daría correctamente a la *segunda* `E` un gris, no un amarillo. + +**✅ Lista de verificación** + + +`score_guess("CRANE", "CRANE")` returns all greens. +`score_guess("SPEED", "ERASE")` returns exactly two yellow `E`s, not more. +A guess and target that share zero letters returns all grays. +You've tried a case where the *guess* repeats a letter but the target only has one copy, and confirmed only one mark comes back non-gray. + + +**🤔 Pregunta(s) socrática(s)** + +Prueba el objetivo `"LLAMA"` y el intento `"ALLOY"` a mano antes de ejecutar el código: `LLAMA` tiene dos `L`s y dos `A`s. Recorre tú mismo ambas pasadas — ¿qué letras terminan en verde, cuáles en amarillo, y cuáles en gris? Luego compara tu respuesta contra `score_guess`. Si te equivocaste en papel, ¿dónde exactamente divergió tu modelo mental del algoritmo de dos pasadas? + +## Paso 2: Construye el bucle del juego + +Con el puntaje sólido, envuélvelo en un juego real: elige un objetivo aleatorio de una lista de palabras, dale al jugador 6 intentos, y detente en cuanto tenga las cinco verdes. + +```python +import random + +MAX_GUESSES = 6 + +def load_words(path="words.txt") -> list[str]: + with open(path) as f: + return [w.strip().upper() for w in f if w.strip()] + +def play_round(words: list[str]) -> tuple[bool, int]: + target = random.choice(words) + for attempt in range(1, MAX_GUESSES + 1): + guess = input(f"Guess {attempt}/{MAX_GUESSES}: ").strip().upper() + marks = score_guess(guess, target) + print(" ".join(f"{l}:{m}" for l, m in zip(guess, marks))) + if all(m == "G" for m in marks): + print(f"You got it in {attempt}!") + return True, attempt + print(f"Out of guesses. The word was {target}.") + return False, MAX_GUESSES +``` + +`words.txt` es un archivo de texto plano, una palabra por línea — el ejemplo real incluye una lista de unas 540 palabras comunes en inglés de 5 letras exactamente para este propósito. Una *lista* de palabras como esta (solo hechos sobre qué cadenas son palabras en inglés, sin expresión creativa) está bien para usar y redistribuir libremente, a diferencia de copiar, digamos, las definiciones reales de un diccionario. + +**✅ Lista de verificación** + + +Each round picks a genuinely random target from the word list (print it temporarily to confirm, then remove the print — no spoilers once you trust it). +The loop stops immediately once all five marks are green, even before 6 guesses are used. +After exactly 6 wrong guesses, the loop ends and reveals the target. + + +**🤔 Pregunta(s) socrática(s)** + +Si `random.choice(words)` se llama una vez por ronda desde dentro de `play_round`, y llamas a `play_round` en un bucle para dejar que alguien juegue de nuevo, ¿el objetivo realmente cambiará entre rondas? ¿Qué pasaría si accidentalmente computaras `target` una vez *fuera* del bucle en su lugar? + +## Paso 3: Valida los intentos contra la lista de palabras + +El Wordle real no te deja adivinar `"ZZZZZ"` — todo intento tiene que ser una palabra real de su diccionario. Añade esa verificación antes de puntuar: + +```python +def read_guess(word_set: set[str]) -> str: + while True: + raw = input(f"Guess ({WORD_LENGTH} letters): ").strip().upper() + if len(raw) != WORD_LENGTH or not raw.isalpha(): + print(f" Please enter exactly {WORD_LENGTH} letters.") + continue + if raw not in word_set: + print(f" '{raw}' isn't in the word list — try a real word.") + continue + return raw +``` + +Usar un `set` aquí en lugar de verificar `raw in words` contra la lista directamente importa más de lo que parece: las verificaciones de membresía de una lista escanean cada entrada una por una, mientras que una verificación de set es casi instantánea sin importar cuántas palabras contenga — un hábito pequeño pero genuinamente bueno para cualquier verificación de "¿está este valor en una colección grande?". + +:::tip[Rechaza la entrada mala temprano, no a mitad del juego] +Validar la *forma* del intento (5 letras, alfabético) antes de verificar la lista de palabras atrapa los errores de tipeo más comunes con la verificación más barata primero — no tiene sentido buscar `"crane5"` en un set de 540 palabras cuando una verificación de `len()` y `.isalpha()` ya te dice que está malformado. +::: + +**✅ Lista de verificación** + + +Guessing a non-word (e.g. `"ZZZZZ"`) prints a clear rejection message and re-prompts, without consuming one of the 6 tries. +Guessing something that isn't 5 letters (too short, too long, contains a digit) is also rejected before it ever reaches the word-list check. +A valid, in-list guess is accepted immediately, lowercase or uppercase. + + +**🤔 Pregunta(s) socrática(s)** + +¿Por qué es importante que `read_guess` vuelva a preguntar sobre un intento malo *dentro de su propio bucle*, en lugar de devolver algún valor centinela como `None` para que el llamador (`play_round`) lo maneje? ¿Qué saldría mal con el conteo de intentos del Paso 2 si se permitiera que un intento inválido consumiera uno de los 6 intentos? + +## Paso 4: Añade seguimiento de estadísticas persistente + +La última pieza: recuerda cómo le ha ido al jugador, entre ejecuciones separadas del programa, no solo dentro de una sesión. Eso significa escribir a un archivo en disco. + +```python +import json +from pathlib import Path + +STATS_FILE = Path("stats.json") + +DEFAULT_STATS = { + "played": 0, + "wins": 0, + "current_streak": 0, + "max_streak": 0, + "guess_distribution": {str(n): 0 for n in range(1, MAX_GUESSES + 1)}, +} + +def load_stats() -> dict: + if not STATS_FILE.exists(): + return json.loads(json.dumps(DEFAULT_STATS)) # a fresh copy + with STATS_FILE.open() as f: + return json.load(f) + +def save_stats(stats: dict) -> None: + with STATS_FILE.open("w") as f: + json.dump(stats, f, indent=2) + +def record_result(stats: dict, won: bool, guesses_used: int) -> dict: + stats["played"] += 1 + if won: + stats["wins"] += 1 + stats["current_streak"] += 1 + stats["max_streak"] = max(stats["max_streak"], stats["current_streak"]) + stats["guess_distribution"][str(guesses_used)] += 1 + else: + stats["current_streak"] = 0 + return stats +``` + +`load_stats` maneja la primera ejecución con elegancia — no existe archivo todavía, así que devuelve un conjunto nuevo de valores por defecto en cero en lugar de fallar por un archivo faltante. Cada otra ejecución carga lo que se guardó la última vez. `record_result` solo añade a `guess_distribution` en una victoria — una derrota no tiene un valor significativo de "intentos usados para ganar", igual que la propia pantalla de estadísticas del Wordle real. + +El bucle completo del juego lo une todo: carga las estadísticas una vez al inicio, actualízalas y guárdalas después de cada ronda. + +```python +words = load_words() +stats = load_stats() + +while True: + won, attempts = play_round(words) + stats = record_result(stats, won, attempts) + save_stats(stats) + print(f"Played: {stats['played']} Win rate: {stats['wins']/stats['played']:.0%} " + f"Streak: {stats['current_streak']}") + if input("Play again? [y/N] ").strip().lower() != "y": + break +``` + +:::tip[Guarda después de cada ronda, no solo al salir] +Llamar a `save_stats(stats)` justo después de `record_result`, cada ronda, significa que un programa interrumpido (terminal cerrada, `Ctrl+C`, fallo) como mucho solo pierde el resultado de la ronda *actual* — nunca el progreso de toda la sesión. Guardar solo una vez al final del programa tiraría todo si el jugador sale a mitad de sesión en lugar de salir por el prompt de "¿jugar de nuevo?". +::: + +**✅ Lista de verificación** + + +Quitting the program and restarting it shows the same `played`/`wins`/streak numbers as before you quit, loaded from `stats.json`. +Winning in, say, 3 guesses increments `guess_distribution["3"]` specifically, not some other key. +Losing a round resets `current_streak` to 0 but does not touch `guess_distribution` or `max_streak`. +Deleting `stats.json` and rerunning the program doesn't crash — it starts a fresh, zeroed stats file instead. + + +**🤔 Pregunta(s) socrática(s)** + +`max_streak` se computa como `max(stats["max_streak"], stats["current_streak"])` después de cada victoria, en lugar de solo actualizarse cuando el *juego* termina. ¿Por qué actualizarlo después de cada victoria (en lugar de intentar computarlo luego desde el historial) rastrea correctamente la mejor racha alcanzada, incluso si el jugador está todavía en su mejor racha ahora mismo y aún no ha perdido? + +## ⚠️ Errores comunes + +- **El error de letras repetidas (Paso 1).** Con mucho el error más común: verificar `letter in target` de forma independiente para cada letra adivinada, sin rastrear qué copias de una letra repetida ya han sido "reclamadas". Esto otorga de más marcas amarillas siempre que el intento o la palabra objetivo repiten una letra. Usa siempre el enfoque de dos pasadas que consume copias — primero los verdes, luego los amarillos contra un grupo de letras objetivo *restantes*. +- **Intentos que no son palabras reales.** Sin validar contra una lista de palabras (Paso 3), los jugadores pueden adivinar `"AEIOU"` o cualquier otra no palabra puramente para sondear qué letras están en la palabra objetivo — una estrategia que el Wordle real bloquea explícitamente al exigir que todo intento sea una palabra del diccionario. +- **Sensibilidad a mayúsculas.** `"crane" == "CRANE"` es `False` en Python. Normaliza todo intento y objetivo al mismo caso (este proyecto usa `.upper()` en todo) en el momento en que entran a tu código, o las comparaciones fallarán silenciosamente para intentos perfectamente válidos. +- **Perder las estadísticas en un fallo.** Solo escribir `stats.json` una vez al salir del programa significa que cualquier fallo, `Ctrl+C`, o terminal cerrada pierde el progreso de toda esa sesión. Guarda después de cada ronda en su lugar (consulta el consejo en el Paso 4). +- **Un archivo de estadísticas de una versión anterior de tu código.** Si añades un nuevo campo a `DEFAULT_STATS` más tarde, `load_stats` como está escrito arriba cargará felizmente un `stats.json` *viejo* al que le falta ese campo, y luego fallará la primera vez que tu código intente leerlo. Vale la pena manejarlo de forma defensiva (consulta cómo `examples/wordle-clone/stats.py` fusiona los datos cargados sobre una copia nueva de los valores por defecto) si planeas seguir ajustando el esquema de estadísticas. + +## Lo que acabas de construir + +Un clon de Wordle real: lógica correcta de retroalimentación de intentos (incluyendo el caso límite de letras repetidas que tropieza a muchos primeros intentos), un bucle de juego interactivo respaldado por una lista de palabras real con validación adecuada de intentos, y estadísticas que genuinamente persisten entre ejecuciones separadas del programa — no solo dentro de una sesión. Nada de eso necesitó nada más allá de la biblioteca estándar y una pequeña biblioteca de colores, lo que vale la pena notar: un proyecto puede ser sustancial y genuinamente divertido sin necesitar una clave de API, un framework, o un servicio en la nube. + +:::tip[Verifica la lógica delicada con casos de prueba, no solo probando jugando] +Es fácil jugar unas rondas, ver una salida de aspecto razonable, y asumir que la lógica de puntuación es correcta — pero el error de letras repetidas específicamente solo aparece en intentos o palabras objetivo con letras repetidas, que no saldrán en cada ronda que casualmente juegues a mano. Escribir un puñado de casos de prueba explícitos (como el ejemplo `SPEED`/`ERASE` del Paso 1) que apunten específicamente a ese caso límite atrapa errores que las pruebas de juego casuales pueden perder por completo. +::: + +## A dónde ir desde aquí + +- **Modo difícil.** El modo difícil del Wordle real requiere que todo intento posterior reutilice cualquier verde/amarillo ya revelado — hacer cumplir eso significa rastrear las restricciones conocidas entre intentos dentro de una ronda, no solo puntuar un intento de forma aislada. +- **Un sistema de pistas.** Revela la posición correcta de una letra aleatoria no adivinada bajo petición, a costa de que cuente contra el total de intentos del jugador (o algún otro compromiso que diseñes). +- **Multijugador o una palabra diaria compartida.** El Wordle real famosamente les da a todos la misma palabra cada día. Derivar el objetivo de hoy de forma determinista desde la fecha (ej. hashear la cadena de la fecha para elegir un índice en la lista de palabras) dejaría que cada jugador viera la misma palabra sin un servidor — un buen ejercicio pequeño de aleatoriedad determinista. +- **Un solucionador simple, como meta adicional.** Dadas las marcas devueltas hasta ahora, filtra la lista de palabras a solo las palabras que siguen siendo consistentes con cada restricción revelada — una inversión divertida de la lógica del juego que acabas de escribir, y un buen ejercicio del mismo razonamiento de letras repetidas del Paso 1, aplicado en la dirección opuesta. + +## 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 recorrido completo y amigable para principiantes sobre cómo agregar 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 1cdb537..6ee7769 100644 --- a/i18n/fr/code.json +++ b/i18n/fr/code.json @@ -933,5 +933,13 @@ "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" + }, + "homepage.projects.triviaBot.title": { + "message": "Construire un Bot de Trivia pour Discord", + "description": "Homepage project card title" + }, + "homepage.projects.triviaBot.summary": { + "message": "Organise des manches de trivia dans un serveur Discord avec discord.py : un classement persistant, et des questions générées à la volée sur n'importe quel sujet avec un LLM de niveau gratuit.", + "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 1fe7d49..985bfbc 100644 --- a/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -166,6 +166,10 @@ Ils sont optionnels et non notés. Parcourez-les à tout moment — l'introducti 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.", + id: 'trivia-bot', + title: 'Construire un Bot de Trivia pour Discord', + summary: + "Construis un bot `discord.py` qui organise des manches de trivia dans un serveur, suit les scores dans un classement persistant, et peut générer des questions inédites sur n'importe quel sujet avec un LLM de niveau gratuit.", }, ])} /> diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json b/i18n/fr/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json new file mode 100644 index 0000000..977f0bc --- /dev/null +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/trivia-bot/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Bot de Trivia pour Discord", + "position": 12 +} diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md b/i18n/fr/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md new file mode 100644 index 0000000..b2be931 --- /dev/null +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/trivia-bot/index.md @@ -0,0 +1,540 @@ +--- +id: trivia-bot +title: "Construire un Bot de Trivia pour Discord" +sidebar_label: "Construire un Bot de Trivia pour Discord" +slug: /projects/trivia-bot +description: "Construis un bot `discord.py` qui organise des manches de trivia dans un serveur, suit les scores dans un classement persistant, et peut générer des questions inédites sur n'importe quel sujet avec un LLM de niveau gratuit." +--- + +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 Bot de Trivia pour Discord + + + + + +Un bot `discord.py` en direct qui organise des manches de trivia dans un serveur : poste une question, recueille les réponses dans un délai, révèle qui a trouvé la bonne réponse, et garde un classement persistant à travers les manches. La plupart des bots de trivia s'arrêtent à une banque de questions fixe — celui-ci ajoute une touche qui convient à un cours de Python : il peut aussi générer une question inédite sur n'importe quel sujet à la volée avec un LLM de niveau gratuit, au lieu de toujours puiser dans une liste préfabriquée. + +Cela suppose du Python de niveau Python 101. Aucun autre Projet du Monde Réel n'est requis au préalable, même si tu as déjà construit [Construire une App RAG](/docs/projects/rag-notes), la configuration du LLM de niveau gratuit ci-dessous te semblera familière. + +C'est optionnel et non noté. Voir [Projets du monde réel](/docs/projects) pour la liste complète et croissante. + +## 🎯 Ce que tu vas faire + +1. Créer une application de bot Discord et récupérer son jeton depuis le portail gratuit des développeurs de Discord. +2. Installer `uv`, configurer un projet, et ajouter `discord.py` en plus d'un client LLM de niveau gratuit. +3. Construire une banque de questions de trivia fixe et une commande slash Discord de base qui en poste une. +4. Ajouter un classement persistant par joueur, stocké entre les redémarrages. +5. Ajouter un mode de génération de questions par LLM : donne un sujet au bot, récupère une question inédite. +6. Brancher le tout dans une boucle de manche complète — poste une question, recueille les réponses dans un délai, révèle la réponse, met à jour le classement. +7. Inviter le bot sur un serveur de test et jouer de vraies manches, de bout en bout. + +## Où exécuter ceci + +**En local avec `uv`** est vraiment la seule option pratique ici, plus que pour la plupart des autres projets de cette série. Un bot Discord n'est pas un script qui s'exécute une fois et se termine — il maintient une connexion ouverte avec Discord et doit continuer à tourner tant que tu veux qu'il réponde à `/trivia` et recueille des réponses, ce qui signifie un vrai processus local (ou hébergé) de longue durée, pas une commande ponctuelle. + +**GitHub Codespaces** fonctionne aussi, et c'est un substitut raisonnable si tu préfères ne rien installer localement : 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 `uv run python bot.py` dans un terminal là-bas — il reste actif aussi longtemps que ce terminal (et le Codespace) reste ouvert, la même exigence de « processus de longue durée » que de l'exécuter en local. + +**Google Colab et Kaggle Notebooks sont un mauvais choix pour le bot réel** — sois honnête avec toi-même à ce sujet plutôt que de lutter contre. Les notebooks sont construits autour du fait d'exécuter une cellule, d'obtenir la sortie, et de passer à la cellule suivante ; ils ne sont pas faits pour un processus en arrière-plan qui s'assoit et attend des événements indéfiniment. Tu *peux* démarrer la boucle d'événements d'un bot dans une cellule de notebook, mais dès que le runtime du notebook est recyclé, se déconnecte, ou que tu fermes l'onglet, le bot tombe avec lui — saute Colab/Kaggle pour le bot en direct et utilise un vrai processus local ou Codespaces à la place. + +Cela dit, la génération de questions et le scoring *sous* le bot ne sont que des fonctions normales qui exécutent une cellule à la fois, ce qui est exactement ce pour quoi les notebooks sont bons. Les badges ci-dessous ouvrent un notebook qui génère de vraies questions LLM sur quelques sujets d'exemple et fait passer quelques « joueurs » factices à travers la logique de scoring, pour que tu puisses voir les deux fonctionner sans rien installer localement. Il s'arrête délibérément avant la couche Discord — pour cela, reviens ici et exécute `bot.py` en local ou dans Codespaces comme décrit ci-dessus. + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/trivia-bot/notebook.ipynb) +[![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/trivia-bot/notebook.ipynb) + +## Configuration + +Tout dans cette section n'a besoin de se produire qu'une seule fois, avant que tu écrives la moindre ligne du bot lui-même : installer `uv`, créer l'application de bot Discord et récupérer son jeton, obtenir une clé LLM gratuite, et configurer le projet. Chaque étape après celle-ci suppose que tout cela est déjà fait. + +### Installer `uv` + +`uv` est un outil unique 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 une application de bot Discord et obtenir un jeton + +Le [Portail des Développeurs](https://discord.com/developers/applications) de Discord est gratuit et ne demande aucune carte : + +1. Connecte-toi et clique sur **New Application**, donne-lui un nom (ex. « trivia-bot »), et crée-la. +2. Ouvre l'onglet **Bot** à gauche. Discord ajoute un utilisateur bot à ton application automatiquement. +3. Clique sur **Reset Token** (ou **View Token** si c'est la première fois) et copie-le. Ce jeton est exactement comme un mot de passe — quiconque l'a peut contrôler ton bot — alors traite-le de la même façon que tu traiterais une clé API LLM : ne le colle jamais dans le code, ne le commite jamais. +4. Sur le même onglet **Bot**, fais défiler jusqu'à **Privileged Gateway Intents** et active **Message Content**. C'est nécessaire pour que le bot lise réellement la lettre avec laquelle un joueur répond — sans cela, `discord.py` reçoit une chaîne vide pour le contenu de chaque message, peu importe le code que tu écris. +5. Ouvre **OAuth2 → URL Generator**. Sous **Scopes**, coche à la fois `bot` et `applications.commands` (les commandes slash ont spécifiquement besoin du second) ; sous **Bot Permissions**, coche au moins **Send Messages** et **Read Message History**. Garde l'URL générée à portée de main — tu l'utiliseras à la dernière étape pour réellement inviter le bot sur un serveur. + +:::tip[Un jeton de bot est un secret, exactement comme une clé API] +Ne code jamais en dur le jeton du bot, ne le commite jamais, et garde-le dans un fichier `.env` local (ci-dessous) à la place — un jeton de bot divulgué permet à n'importe qui d'usurper ton bot dans chaque serveur où il se trouve, exactement comme une clé LLM divulguée permet à n'importe qui de dépenser ton quota. +::: + +### Obtenir une clé API LLM gratuite + +Le mode de génération de questions a besoin d'une clé LLM de niveau gratuit — **choisis le fournisseur que tu veux**, aucun ne demande de carte de crédit au moment de la rédaction : + +| Fournisseur | Où obtenir une clé | Pourquoi le choisir | +|---|---|---| +| **GitHub Models** *(défaut suggéré)* | [github.com/settings/tokens](https://github.com/settings/tokens) — un jeton d'accès personnel avec le scope `models: read` | Pas d'inscription séparée — tu as déjà un compte GitHub. 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 citée. | +| Groq | [console.groq.com/keys](https://console.groq.com/keys) | Inférence rapide, niveau gratuit généreux, sans 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 de jetons quotidien élevé, sans carte. | +| OpenRouter | [openrouter.ai/keys](https://openrouter.ai/keys) | Une API, de nombreux modèles gratuits — bien pour comparer les fournisseurs. | + +La banque de questions fixe (Étape 1) n'a besoin d'aucune clé LLM — tu n'en as besoin qu'une fois arrivé à la génération de questions par sujet de l'Étape 3. + +### Configurer le projet + +```bash +uv init trivia-bot +cd trivia-bot +uv add discord.py openai python-dotenv +``` + +`discord.py` est la bibliothèque qui parle à Discord — se connectant à sa Gateway, enregistrant les commandes slash, et recevant/envoyant des messages. `openai` parle à l'endpoint compatible OpenAI de GitHub Models pour le fournisseur par défaut ci-dessus ; remplace-le par le paquet de ton propre fournisseur si tu en as choisi un autre. `python-dotenv` charge les secrets depuis un fichier `.env` local. + +Crée un fichier `.env` dans le dossier du projet (ne le commite jamais) avec **les deux** secrets de cette section : + +```bash +# .env +DISCORD_BOT_TOKEN=your-bot-token-here +GITHUB_TOKEN=your-llm-key-here +``` + +**✅ Liste de vérification** + + +Une application Discord et un bot existent dans le Portail des Développeurs, et tu as copié son jeton. +« Message Content » est activé sous Privileged Gateway Intents. +Tu as une clé API LLM de niveau gratuit d'un fournisseur de ton choix. +`uv init`/`uv add` se sont terminés sans erreur, et `.env` a à la fois `DISCORD_BOT_TOKEN` et ta clé LLM configurés. + + +**🤔 Question(s) socratique(s)** + +- Pourquoi Discord exige-t-il que tu actives explicitement « Message Content » comme une intention *privilégiée*, plutôt que de donner à chaque bot l'accès au texte des messages par défaut ? +- Le jeton du bot et la clé API LLM sont tous deux des secrets, mais ils s'authentifient auprès de deux services complètement différents. Qu'est-ce qui irait mal si tu échangeais accidentellement quelle variable d'environnement contient quelle valeur ? + +## Étape 1 : Une banque de questions fixe et une commande slash de base + +Commence par la source de questions la plus simple possible — une liste Python plate de dicts — et juste assez de câblage Discord pour en poster une : + +```python +# questions.py +"""A small fixed bank of trivia questions. Every question, from this bank +or later generated by an LLM, is the same shape: +{"question": str, "options": list[str], "answer_index": int}.""" + +import random + +QUESTION_BANK = [ + { + "question": "What year was Python first released?", + "options": ["1989", "1991", "1995", "2000"], + "answer_index": 1, + }, + { + "question": "Which planet is known as the Red Planet?", + "options": ["Venus", "Jupiter", "Mars", "Saturn"], + "answer_index": 2, + }, + # ... a handful more, see examples/trivia-bot/questions.py for the full bank +] + + +def random_question() -> dict: + return random.choice(QUESTION_BANK) +``` + +L'interface moderne de `discord.py` pour cela est une **commande slash** : au lieu de surveiller chaque message pour quelque chose qui ressemble à une commande, tu enregistres `/trivia` auprès de Discord lui-même, et Discord l'affiche dans l'interface avec autocomplétion. Cela nécessite un `Client` plus un `app_commands.CommandTree` qui lui est attaché : + +```python +# bot.py (Step 1 version — grows through the rest of this project) +import os + +import discord +from discord import app_commands +from dotenv import load_dotenv + +from questions import random_question + +load_dotenv() + +intents = discord.Intents.default() +intents.message_content = True # requires the portal toggle from Setup, too + +client = discord.Client(intents=intents) +tree = app_commands.CommandTree(client) + + +@tree.command(name="trivia", description="Start a trivia round") +async def trivia_command(interaction: discord.Interaction) -> None: + question = random_question() + lines = [f"**{question['question']}**"] + for letter, option in zip("ABCD", question["options"]): + lines.append(f"{letter}) {option}") + await interaction.response.send_message("\n".join(lines)) + + +@client.event +async def on_ready() -> None: + await tree.sync() # registers /trivia with Discord -- can take a minute the first time + print(f"Logged in as {client.user} -- ready in {len(client.guilds)} server(s).") + + +if __name__ == "__main__": + client.run(os.environ["DISCORD_BOT_TOKEN"]) +``` + +`tree.sync()` est ce qui publie réellement `/trivia` sur Discord pour qu'il apparaisse quand quelqu'un tape `/` dans ton serveur — saute-le et la commande existe dans ton code mais nulle part où l'interface de Discord peut la trouver. + +:::tip[Les commandes slash ont besoin d'un second scope OAuth2] +Une invitation de bot normale ne nécessite que le scope `bot`. Les commandes slash ont spécifiquement besoin aussi de `applications.commands` — si tu as généré ton URL d'invitation avant d'ajouter `/trivia`, régénère-la avec les deux scopes cochés (voir Configuration ci-dessus) ou la commande n'apparaîtra jamais en silence dans ton serveur. +::: + +**✅ Liste de vérification** + + +`questions.py` définit `QUESTION_BANK` et `random_question()`. +`bot.py` enregistre une commande slash `/trivia` via `app_commands.CommandTree`. +`on_ready` appelle `await tree.sync()` avant d'imprimer son message de prêt. + + +**🤔 Question(s) socratique(s)** + +- `tree.sync()` ré-enregistre chaque commande slash auprès des serveurs de Discord, ce qui est limité en débit. Qu'est-ce qui irait mal si tu l'appelais à l'intérieur de `trivia_command` au lieu d'une fois dans `on_ready` ? +- Le `answer_index` du dict de la question pointe vers `options` par position plutôt que de stocker le texte de la bonne réponse directement. Quel est un avantage de le stocker de cette façon ? + +## Étape 2 : Suivi des scores, persisté à travers les manches + +Un classement ne veut dire quelque chose que s'il survit au redémarrage du bot, donc les scores vont dans un petit fichier JSON plutôt que de vivre uniquement en mémoire : + +```python +# scores.py +"""Per-player score persistence in scores.json. Keyed by Discord user id +(not username), so a player's score survives a nickname change.""" + +import json +from pathlib import Path + +SCORES_PATH = Path("scores.json") + + +def load_scores() -> dict: + if not SCORES_PATH.exists(): + return {} + return json.loads(SCORES_PATH.read_text(encoding="utf-8")) + + +def save_scores(scores: dict) -> None: + SCORES_PATH.write_text(json.dumps(scores, indent=2), encoding="utf-8") + + +def award_point(scores: dict, user_id: int, display_name: str) -> dict: + key = str(user_id) + entry = scores.get(key, {"name": display_name, "score": 0}) + entry["name"] = display_name + entry["score"] += 1 + scores[key] = entry + save_scores(scores) + return scores + + +def leaderboard_text(scores: dict, top_n: int = 10) -> str: + if not scores: + return "No scores yet -- play a round with `/trivia`!" + ranked = sorted(scores.values(), key=lambda entry: entry["score"], reverse=True) + lines = [f"{i}. {entry['name']} — {entry['score']}" for i, entry in enumerate(ranked[:top_n], start=1)] + return "\n".join(lines) +``` + +Teste-le de manière autonome avant de le brancher dans `bot.py` du tout — le même motif « prouve que la pièce fonctionne seule d'abord » que n'importe quel projet en plusieurs parties : + +```bash +uv run python -c " +from scores import award_point, leaderboard_text +s = {} +s = award_point(s, 111, 'Alice') +s = award_point(s, 222, 'Bob') +s = award_point(s, 111, 'Alice') +print(leaderboard_text(s)) +" +``` + +Puis ajoute une seconde commande slash qui lit simplement le fichier : + +```python +@tree.command(name="leaderboard", description="Show the trivia leaderboard") +async def leaderboard_command(interaction: discord.Interaction) -> None: + scores = load_scores() + await interaction.response.send_message(f"**Leaderboard:**\n{leaderboard_text(scores)}") +``` + +Rien n'attribue encore de point — `trivia_command` de l'Étape 1 ne vérifie pas du tout les réponses — c'est ce que la boucle de manche de l'Étape 4 ajoute. Cette étape n'est délibérément que la moitié stockage, testée et fonctionnant seule d'abord. + +**✅ Liste de vérification** + + +`scores.py` définit `load_scores()`, `award_point()`, et `leaderboard_text()`. +Exécuter le test autonome de `scores.py` imprime un classement avec Alice au-dessus de Bob. +`/leaderboard` est enregistré dans `bot.py` et répond avec le classement (encore vide). + + +**🤔 Question(s) socratique(s)** + +- Les scores sont indexés par `str(user_id)` plutôt que par le nom d'affichage du joueur. Quel scénario réel casserait un classement indexé par nom qu'un classement indexé par ID d'utilisateur survit ? +- `save_scores()` réécrit tout le fichier à chaque point individuel. Pour un petit bot mono-serveur, c'est très bien — à quel moment cela cesserait-il de l'être, et vers quoi te tournerais-tu à la place ? + +## Étape 3 : Générer une question inédite sur n'importe quel sujet avec un LLM + +La banque fixe de l'Étape 1 ne puise toujours que dans la même poignée de questions. Cette étape ajoute une seconde source de questions : donne un sujet au bot, et il demande à un LLM une toute nouvelle question à choix multiples sur ce sujet, à la volée. + +```python +# generate.py +"""Generates a fresh trivia question on a topic via a free-tier LLM. +Returns the exact same shape as questions.py's bank entries, so the rest +of the bot doesn't need to know or care where a question came from.""" + +import json +import os + +from openai import OpenAI + +llm_client = OpenAI( + api_key=os.environ["GITHUB_TOKEN"], + base_url="https://models.github.ai/inference", +) + +PROMPT_TEMPLATE = """Write one multiple-choice trivia question about: {topic} + +Respond with ONLY a JSON object, no other text, in exactly this shape: +{{"question": "...", "options": ["...", "...", "...", "..."], "answer_index": 0}} + +Requirements: +- Exactly 4 options. +- Exactly one is correct; put its index (0-3) in answer_index. +- The wrong options must be plausible, not obviously silly. +- Keep the question and every option short enough to fit in a Discord message.""" + + +def generate_question(topic: str) -> dict: + response = llm_client.chat.completions.create( + model="gpt-4o-mini", # confirm this still has a free tier before running + messages=[{"role": "user", "content": PROMPT_TEMPLATE.format(topic=topic)}], + response_format={"type": "json_object"}, + ) + question = json.loads(response.choices[0].message.content) + + options = question.get("options") + answer_index = question.get("answer_index") + if not question.get("question") or not isinstance(options, list) or len(options) != 4: + raise ValueError(f"LLM returned a malformed question: {question!r}") + if not isinstance(answer_index, int) or not (0 <= answer_index < 4): + raise ValueError(f"LLM returned an invalid answer_index: {question!r}") + return question +``` + +La vérification explicite de la forme après l'analyse compte : `response_format={"type": "json_object"}` garantit que la sortie du LLM est *du JSON valide*, pas que c'est *le bon* JSON — il pourrait encore renvoyer trois options au lieu de quatre, ou omettre `answer_index` complètement. L'attraper ici, avec une erreur claire, vaut mieux que de le découvrir plus tard comme un message Discord déroutant avec une option D manquante. + +Branche un paramètre `topic` dans `/trivia` pour qu'il puisse puiser dans l'une ou l'autre source : + +```python +from round import pick_question # combines random_question() and generate_question() +``` + +```python +# round.py +"""Non-Discord round logic shared by bot.py and the notebook.""" + +from generate import generate_question +from questions import random_question + + +def pick_question(topic: str | None = None) -> dict: + if topic: + return generate_question(topic) + return random_question() +``` + +```python +@tree.command(name="trivia", description="Start a trivia round, optionally on a topic") +@app_commands.describe(topic="Optional topic for a freshly generated question") +async def trivia_command(interaction: discord.Interaction, topic: str | None = None) -> None: + question = pick_question(topic) + ... +``` + +Teste les deux chemins depuis un terminal avant de leur faire confiance à l'intérieur de Discord : + +```bash +uv run python -c "from round import pick_question; print(pick_question())" +uv run python -c "from round import pick_question; print(pick_question('classic video games'))" +``` + +:::tip[Valide le contenu généré par LLM avant qu'il n'atteigne un canal en direct] +Un LLM à qui l'on demande une question de trivia peut encore se tromper sur les faits, surtout sur des sujets obscurs — il n'y a pas de `try`/`except` qui attrape « incorrect avec confiance ». La validation de forme dans `generate_question()` ne protège que contre une *structure* malformée ; pour un serveur public, parcours une poignée de questions générées sur des sujets que tu connais vraiment avant de faire confiance au mode sur des sujets que tu ne connais pas. +::: + +**✅ Liste de vérification** + + +`generate_question(topic)` de `generate.py` renvoie un dict avec 4 options et un `answer_index` valide, ou lève une erreur claire. +`pick_question()` de `round.py` renvoie une question de banque quand `topic` est vide, et une générée sinon. +`/trivia` accepte un argument `topic` optionnel et l'utilise visiblement. + + +**🤔 Question(s) socratique(s)** + +- `generate_question()` valide que `answer_index` est un int dans `0..3` et qu'il y a exactement 4 options, mais il ne valide pas que le *contenu* est réellement de la bonne trivia. Où se trouve la ligne entre ce que le code peut raisonnablement vérifier et ce que seul un humain qui revoit la sortie peut faire ? +- Si un joueur choisit un sujet délibérément offensant ou absurde, quelle est la pire chose plausible que `generate_question()` pourrait renvoyer, et qu'ajouterais-tu pour t'en protéger ? + +## Étape 4 : Une boucle de manche de trivia complète + +Jusqu'ici, tout était des pièces testées de manière isolée : une source de questions, le stockage des scores, la génération. Cette étape les branche dans ce à quoi ressemble réellement une manche en direct — poste une question, attends la première bonne réponse dans un délai, révèle-la, mets à jour le classement : + +```python +# bot.py (relevant part -- see examples/trivia-bot/bot.py for the full file) +import asyncio + +from round import OPTION_LETTERS, check_answer, format_question, pick_question +from scores import award_point, leaderboard_text, load_scores + +ROUND_TIME_LIMIT = 30 # seconds + + +async def run_round(channel: discord.abc.Messageable, topic: str | None = None) -> None: + question = pick_question(topic) + valid_letters = OPTION_LETTERS[: len(question["options"])] + await channel.send( + f"{format_question(question)}\n\nYou have {ROUND_TIME_LIMIT}s -- " + f"reply with just the letter ({'/'.join(valid_letters)})." + ) + + def is_candidate_answer(message: discord.Message) -> bool: + return ( + message.channel == channel + and not message.author.bot + and message.content.strip().upper() in valid_letters + ) + + loop = asyncio.get_event_loop() + deadline = loop.time() + ROUND_TIME_LIMIT + winner = None + + while True: + remaining = deadline - loop.time() + if remaining <= 0: + break + try: + message = await client.wait_for("message", check=is_candidate_answer, timeout=remaining) + except asyncio.TimeoutError: + break + if check_answer(question, message.content): + winner = message.author + break + await message.add_reaction("❌") + + correct_letter = OPTION_LETTERS[question["answer_index"]] + correct_text = question["options"][question["answer_index"]] + + if winner is not None: + scores = award_point(load_scores(), winner.id, str(winner.display_name)) + await channel.send( + f"✅ {winner.mention} got it! The answer was **{correct_letter}) {correct_text}**.\n\n" + f"**Leaderboard:**\n{leaderboard_text(scores)}" + ) + else: + await channel.send(f"⏰ Time's up! Nobody got it. The answer was **{correct_letter}) {correct_text}**.") +``` + +`client.wait_for("message", check=..., timeout=...)` est la façon de `discord.py` de mettre en pause une fonction `async` jusqu'à ce qu'un type d'événement spécifique se produise — ici, tout message dans le même canal dont le contenu est exactement l'une des lettres de réponse valides. La boucle `while` le rappelle avec un timeout `remaining` qui diminue, afin que le budget de temps *total* de la manche soit `ROUND_TIME_LIMIT`, pas `ROUND_TIME_LIMIT` par mauvaise réponse — sans recalculer `remaining`, un canal plein de mauvaises réponses enthousiastes pourrait garder la manche ouverte indéfiniment. + +Seule la *première* bonne réponse marque ; fais `break` dès que `winner` est défini. Les mauvaises réponses reçoivent une réaction ❌ au lieu d'un message d'erreur — un retour gratuit sans inonder le canal de réponses. + +Enfin, `trivia_command` de l'Étape 1 devient une fine enveloppe autour de `run_round` : + +```python +@tree.command(name="trivia", description="Start a trivia round, optionally on a topic") +@app_commands.describe(topic="Optional topic for a freshly generated question") +async def trivia_command(interaction: discord.Interaction, topic: str | None = None) -> None: + starting_text = f"🎲 Starting a round about **{topic}**..." if topic else "🎲 Starting a round..." + await interaction.response.send_message(starting_text) + try: + await run_round(interaction.channel, topic) + except Exception as error: # keep the bot alive even if one round fails + print(f"Error running trivia round: {error!r}") + await interaction.channel.send("Something went wrong running that round -- see the bot's console log.") +``` + +:::tip[Teste d'abord le timing de la manche avec un ROUND_TIME_LIMIT court] +Mets `ROUND_TIME_LIMIT = 5` pendant que tu règles la boucle, pour ne pas attendre 30 secondes par cycle de test pour découvrir que `check_answer` a un bug. Remonte-le à quelque chose de raisonnable pour le vrai jeu une fois que la boucle elle-même fonctionne. +::: + +**✅ Liste de vérification** + + +`/trivia` poste une question, puis attend réellement une réponse au lieu de se résoudre instantanément. +La première bonne réponse dans le délai est annoncée comme gagnante et reçoit un point via `award_point()`. +Laisser le minuteur expirer sans bonne réponse révèle la réponse sans planter ni se bloquer. +Exécuter `/trivia` deux fois de suite démarre une nouvelle manche à chaque fois, en utilisant le classement mis à jour. + + +**🤔 Question(s) socratique(s)** + +- `is_candidate_answer` vérifie `message.channel == channel` pour que les réponses des autres canaux du serveur ne comptent pas. Qu'arriverait-il à une manche dans un serveur occupé si cette vérification manquait ? +- Le `try`/`except Exception` autour de `run_round(...)` attrape *n'importe quelle* exception et publie une erreur générique au lieu de planter. Quel est le compromis d'attraper aussi largement dans un bot de longue durée par rapport au fait de laisser un vrai bug faire planter le processus bruyamment ? + +## Invite le bot et joue une vraie manche + +En utilisant l'URL OAuth2 que tu as générée dans Configuration (avec les deux scopes `bot` et `applications.commands`), ouvre-la dans un navigateur et choisis un serveur que tu contrôles — crée un serveur de test gratuit si tu n'en as pas déjà un. + +```bash +uv run python bot.py +``` + +Tu devrais voir imprimé `Logged in as trivia-bot#1234 -- ready in 1 server(s).`. Dans le serveur de test, tape `/trivia` et choisis-le dans le menu d'autocomplétion de Discord — avec ou sans `topic`. En quelques secondes, tu devrais voir la question postée, et après avoir répondu correctement (ou laissé le minuteur expirer) la réponse révélée et le classement mis à jour. Exécute `/leaderboard` à tout moment pour vérifier les scores sans démarrer une nouvelle manche. + +## ⚠️ Pièges courants + +- **Oublier l'intention privilégiée « Message Content ».** Cela doit être activé à *deux* endroits — `intents.message_content = True` dans le code, **et** l'interrupteur sous Bot → Privileged Gateway Intents dans le Portail des Développeurs. Rate l'interrupteur du portail et `message.content` est silencieusement une chaîne vide pour chaque message, donc `is_candidate_answer` ne correspond jamais à aucune réponse, peu importe comment elle est tapée. +- **Confondre le jeton du bot avec le secret client OAuth2.** Le Portail des Développeurs montre les deux sur des onglets différents. Le jeton du bot (onglet Bot) est ce dont `client.run(...)` a besoin ; le secret client (onglet OAuth2) est pour un flux d'authentification complètement différent que ce projet n'utilise jamais. Coller le secret client dans `DISCORD_BOT_TOKEN` échoue à se connecter avec une erreur déroutante. +- **`/trivia` n'apparaît jamais dans l'interface de Discord.** C'est généralement l'une des deux causes : `tree.sync()` n'a jamais été appelé (ou jamais attendu) dans `on_ready`, ou l'URL d'invitation du bot a été générée avant d'ajouter le scope `applications.commands`. Régénère l'URL d'invitation avec les deux scopes et ré-invite le bot si c'est le second qui pose problème. +- **Limites de débit sur le niveau gratuit du LLM, pires avec plusieurs manches d'affilée.** Chaque appel `/trivia ` est une requête LLM séparée contre le quota de niveau gratuit de ton fournisseur, et un serveur occupé qui enchaîne plusieurs manches peut l'atteindre plus vite que ce que tu attendrais des seuls tests. Une erreur 429 n'est pas un bug — ajoute une courte nouvelle tentative avec backoff autour de `generate_question()`, ou retombe sur la banque fixe quand la génération échoue. +- **Une manche qui ne se termine jamais parce que `remaining` n'est pas recalculé.** Si tu copies la boucle de manche mais que tu appelles `client.wait_for(..., timeout=ROUND_TIME_LIMIT)` (la constante fixe) au lieu de la valeur `remaining` qui diminue, chaque mauvaise réponse redémarre effectivement le chronomètre — la manche peut durer bien plus longtemps que ce que `ROUND_TIME_LIMIT` promet réellement. + +## Ce que tu viens de construire + +Un bot de trivia Discord en direct avec deux sources de questions — une banque fixe et la génération par LLM de niveau gratuit sur n'importe quel sujet — une boucle de manche complète avec un vrai timing, et un classement persistant par joueur qui survit aux redémarrages. La source de questions, le scoring, et la logique de manche (`questions.py`, `generate.py`, `scores.py`, `round.py`) sont tous du Python simple sans `discord`, testés indépendamment avant de toucher un canal en direct ; seul `bot.py` sait que Discord existe du tout. Cette séparation vaut la peine d'être gardée à l'esprit en général : les quatre mêmes modules pourraient se retrouver derrière un bot Slack, un formulaire web, ou un jeu CLI à la place, sans aucun changement dans aucun d'entre eux. + +## Où aller à partir d'ici + +- Ajoute un **mode de jeu multi-manches** — `/trivia rounds:5` qui joue plusieurs questions d'affilée et annonce un gagnant global à la fin, au lieu d'une question par commande. +- Suis les **balises de difficulté ou de catégorie** sur les questions générées (demande au LLM d'en inclure une dans sa réponse JSON) et laisse les joueurs choisir une catégorie avec `/trivia topic:... difficulty:hard`. +- Ajoute un **classement par serveur** au lieu d'un `scores.json` global — indexe `scores.json` par `(guild_id, user_id)` au lieu de seulement `user_id`, pour que deux serveurs Discord différents qui exécutent ce bot ne partagent pas un classement. +- Déploie le bot quelque part qui reste allumé sans que ton ordinateur portable tourne — une petite VM toujours active, ou un niveau gratuit sur une plateforme comme Railway ou Fly.io — pour qu'il continue d'héberger des soirées trivia même quand tu n'es pas devant ta machine. + +## 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 étudiants — et son README a un parcours complet et adapté aux débutants pour ajouter le tien via une **pull request**, même si tu n'as jamais utilisé git auparavant : 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 de git n'est supposée. + +Bienvenue à l'écriture de Python hors du navigateur. 🎓 + + diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md b/i18n/fr/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md new file mode 100644 index 0000000..97f36cf --- /dev/null +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/webcam-object-counter/index.md @@ -0,0 +1,302 @@ +--- +id: 2027-webcam-object-counter +title: "Compte des Objets en Temps Réel avec une Caméra Web" +sidebar_label: "Compteur d'Objets avec Caméra Web" +slug: /projects/webcam-object-counter +description: "Compte des objets en direct depuis le flux d'une caméra web avec OpenCV et un modèle YOLO11n pré-entraîné — ou exécute la même détection sur une image ou une vidéo d'exemple fournie sans aucune caméra." +--- + +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'; + +# 🌍 Compte des Objets en Temps Réel avec une Caméra Web + + + + + +Ce projet suppose que tu es à l'aise avec le Python 101 — fonctions, boucles et installation de paquets — et ne nécessite aucun bagage préalable en analyse de données ou apprentissage automatique. C'est la première incursion de ce cours dans la vision par ordinateur : au lieu de charger un modèle pré-entraîné qui lit du texte ou des lignes tabulaires, tu chargeras un modèle qui lit des pixels, et tu l'utiliseras pour répondre à une question authentiquement pratique en temps réel — « combien de *ceci* y a-t-il devant la caméra en ce moment ? » + +C'est optionnel et non noté. Voir [Projets du monde réel](/docs/projects) pour la liste complète et croissante. + +## 🎯 Ce que tu vas faire + +1. Installer `uv` et configurer un projet local avec OpenCV et un modèle de détection d'objets pré-entraîné. +2. Exécuter la détection sur une seule image d'exemple fournie et dessiner des cadres englobants autour de ce qu'elle trouve. +3. Compter les objets d'une classe cible (p. ex. `person`) et afficher un total cumulé. +4. Traiter une courte vidéo d'exemple fournie image par image. +5. Brancher la même boucle de détection sur ta propre caméra web pour un comptage en direct et en temps réel. + +## Où exécuter ceci + +**En local avec `uv` est la seule façon d'obtenir l'expérience complète de caméra web en direct.** Une caméra web physique connectée à ton ordinateur est du matériel — il n'y a pas de route depuis un onglet de navigateur tournant dans le cloud jusqu'à une caméra posée sur ton bureau. Les étapes 1 à 5 ci-dessous supposent cette voie, et l'Étape 5 en particulier ne fonctionnera tout simplement pas ailleurs. + +- **GitHub Codespaces** te donne un environnement de développement cloud sans configuration (Node, Python et `uv` déjà installés — voir [`.devcontainer/devcontainer.json`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/.devcontainer/devcontainer.json)), et les Étapes 1 à 4 (image d'exemple, comptage, vidéo d'exemple) y fonctionnent très bien. L'Étape 5 non — un Codespace tourne sur un serveur distant sans accès à ta caméra web locale non plus. +- **Google Colab, Kaggle Notebooks, ou Binder** sont bons pour la variante **image-d'exemple-uniquement** de ce projet, pas la caméra web en direct. Un notebook réel et exécutable qui télécharge les images d'exemple fournies et exécute le même code de détection vit dans [`examples/webcam-object-counter/notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/webcam-object-counter/notebook.ipynb) (pointera vers `main` une fois fusionné). Clique sur un badge pour le lancer directement : + + [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/webcam-object-counter/notebook.ipynb) + [![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/webcam-object-counter/notebook.ipynb) + [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fwebcam-object-counter%2Fnotebook.ipynb) + + Sois honnête avec toi-même sur ce que cela t'apporte : uniquement la détection d'images d'exemple, pas un flux de caméra en direct. C'est une manière authentiquement bonne de voir le modèle fonctionner avec zéro installation, mais ce n'est pas le même projet que l'Étape 5. + +## Configuration + +`uv` est un outil unique qui remplace la chaîne habituelle « installe Python, puis installe pip, puis installe un outil d'environnement virtuel, puis installe des paquets » — il peut installer et gérer lui-même les versions de Python, 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 qu'il est installé : + +```bash +uv --version +``` + +Ensuite, configure le projet : + +```bash +uv init webcam-object-counter +cd webcam-object-counter +uv add opencv-python ultralytics +``` + +Aucune clé API n'est nécessaire nulle part dans ce projet — la détection tourne entièrement en local, sans aucun service externe impliqué. Fais attention à la taille, cependant : `opencv-python` et `ultralytics` (qui entraîne PyTorch) sont un vrai téléchargement — attends-toi à ce que ce `uv add` prenne quelques minutes et quelques centaines de mégaoctets d'espace disque la première fois. + +:::tip[Deux façons de détecter des objets — choisis celle qui correspond] +OpenCV embarque des **cascades de Haar** intégrées — petites, rapides, sans téléchargement supplémentaire, mais limitées : chaque cascade est entraînée pour une chose spécifique (l'exemple classique est `haarcascade_frontalface_default.xml` pour les visages de face) et fonctionne mieux sur une vue de face assez propre. Ce projet utilise plutôt **YOLO11n** via le paquet `ultralytics` — un modèle de détection d'objets petit (quelques mégaoctets) mais authentiquement moderne, pré-entraîné sur les 80 classes d'objets courants du jeu de données COCO (personne, voiture, chien, bus, chaise, et plus), qui reconnaît bien plus que des visages et gère bien mieux les scènes réelles plus désordonnées. Le compromis honnête : YOLO11n est une installation plus lourde et un peu plus lent par image qu'une cascade de Haar, mais il détecte de vrais objets, pas seulement des visages, ce qui est tout l'intérêt d'un projet « compter des objets » à usage général. Si tu n'as jamais besoin que de détecter des visages, une cascade de Haar est une alternative tout à fait raisonnable et plus légère qui vaut la peine d'être connue. +::: + +## Étape 1 : Détecte des objets dans une seule image d'exemple + +Chaque script ci-dessous réutilise cette même idée centrale. `yolo11n.pt` est un point de contrôle pré-entraîné — `ultralytics` le télécharge automatiquement la première fois que tu construis `YOLO(...)`, et le met en cache localement ensuite : + +```python +# detect_image.py +import cv2 +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") + +results = model("samples/street.jpg") +result = results[0] + +print(f"Detected {len(result.boxes)} object(s):") +for box in result.boxes: + class_name = model.names[int(box.cls)] + confidence = float(box.conf) + print(f" - {class_name} ({confidence:.0%} confidence)") + +annotated = result.plot() # draws boxes + labels on a copy of the image +cv2.imwrite("output_street.jpg", annotated) +``` + +```bash +uv run python detect_image.py +``` + +`model(image_path)` exécute tout le pipeline de détection en un seul appel : redimensionne l'image, la fait passer dans le réseau, et convertit la sortie brute en une liste de cadres, chacun avec un libellé de classe et un score de confiance. `result.boxes` est cette liste — `box.cls` est un index de classe dans `model.names` (un dict des 80 noms de classes COCO), et `box.conf` est la confiance du modèle que le cadre contient effectivement cette classe. `result.plot()` est une méthode de commodité qui redessine tout cela sur l'image pour toi, afin que tu n'aies pas à écrire ta propre boucle de dessin de cadres avec `cv2.rectangle`. + +**✅ Liste de vérification** + + +Exécuter le script affiche au moins un objet détecté avec un nom de classe et un score de confiance. +`output_street.jpg` existe et, ouvert dans un visualiseur d'images, montre des cadres dessinés autour de vrais objets dans l'image. +Tu peux expliquer, en une phrase, ce que `box.cls` et `box.conf` représentent chacun. + + +**🤔 Question(s) socratique(s)** + +Le modèle renvoie un score de confiance pour chaque cadre, pas seulement un oui/non « objet ici ». Si tu filtrais tout cadre dont la confiance est inférieure à 90 %, t'attendrais-tu à voir plus de fausses détections ou plus de détections manquées — et laquelle de ces deux erreurs compte le plus pour un projet dont tout l'intérêt est un *comptage* précis ? + +## Étape 2 : Compte une classe cible et garde un total cumulé + +Détecter tout est un bon début, mais « compter des objets » signifie généralement compter *un type* de chose — des personnes passant par une porte, des voitures dans un parking, et ainsi de suite : + +```python +# count_class.py +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") + +target_class = "person" +image_paths = ["samples/street.jpg", "samples/people.jpg"] + +running_total = 0 +for image_path in image_paths: + result = model(image_path, verbose=False)[0] + count = sum(1 for box in result.boxes if model.names[int(box.cls)] == target_class) + running_total += count + print(f"{image_path}: {count} {target_class}(s) -- running total: {running_total}") + +print(f"\nTotal {target_class}(s): {running_total}") +``` + +```bash +uv run python count_class.py +``` + +Le comptage n'est qu'un filtre-et-somme sur `result.boxes`, comparant le nom de classe de chaque cadre à celui qui t'intéresse. `verbose=False` réduit au silence la journalisation propre à `ultralytics` pour que tes propres instructions `print` ne soient pas enterrées dessous. + +**✅ Liste de vérification** + + +Le script affiche un comptage par image et un total cumulé qui ne fait que monter. +Changer `target_class` en une classe COCO différente (p. ex. `"bus"`) change les comptages affichés en conséquence. +Tu comprends pourquoi cela réutilise `model.names[int(box.cls)]` plutôt que de coder en dur un numéro d'index de classe. + + +**🤔 Question(s) socratique(s)** + +Si deux personnes sur une photo sont debout si proches que leurs cadres englobants se chevauchent presque entièrement, y a-t-il un moyen réaliste pour cette approche de comptage de les sous-compter ou sur-compter ? Qu'est-ce que tu regarderais dans `result.boxes` pour vérifier ? + +## Étape 3 : Traite une courte vidéo d'exemple image par image + +Une vidéo n'est qu'une séquence d'images — exactement le même code de détection par image des Étapes 1-2, exécuté une fois par image dans une boucle : + +```python +# detect_video.py +import cv2 +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") +target_class = "person" + +cap = cv2.VideoCapture("samples/sample_street.mp4") +fps = cap.get(cv2.CAP_PROP_FPS) or 15 +width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) +height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) +writer = cv2.VideoWriter("output_video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)) + +while True: + ok, frame = cap.read() + if not ok: + break # end of the video file, not a broken camera + + result = model(frame, verbose=False)[0] + count = sum(1 for box in result.boxes if model.names[int(box.cls)] == target_class) + + annotated = result.plot() + cv2.putText(annotated, f"{target_class}s: {count}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) + writer.write(annotated) + +cap.release() +writer.release() +``` + +```bash +uv run python detect_video.py +``` + +`cv2.VideoCapture` lit un fichier vidéo (ou, à l'Étape 4, une caméra en direct) une image à la fois via `.read()`, qui renvoie `(ok, frame)` — `ok` devient `False` une fois qu'il n'y a plus d'images. `cv2.VideoWriter` est la même idée en sens inverse : il accumule les images que tu lui donnes dans un nouveau fichier vidéo. Note que le `if not ok: break` ici signifie « le fichier est terminé » — l'Étape 4 réutilise exactement cette même vérification, mais là elle signifie quelque chose d'important et de différent. + +**✅ Liste de vérification** + + +`output_video.mp4` existe et se lit, montrant des cadres englobants et un comptage en direct superposés sur chaque image. +Tu peux expliquer ce que renvoie `cap.read()` et pourquoi la boucle vérifie `ok` avant d'utiliser `frame`. +Tu as remarqué que le comptage peut scintiller d'une image à l'autre même si rien dans la scène n'a visiblement changé. + + +**🤔 Question(s) socratique(s)** + +Le comptage que tu affiches est un instantané par image, pas un total par vidéo — faire passer la même personne devant la caméra pendant trois secondes pourrait la compter dans chaque image. Que nécessiterait « compter combien de personnes *distinctes* ont traversé le cadre », au-delà de ce que ce script fait actuellement ? + +## Étape 4 : Passe en direct avec ta caméra web + +Même boucle, une ligne différente : échange le chemin du fichier vidéo contre `0`, l'index de la caméra par défaut de ton ordinateur : + +```python +# detect_webcam.py +import cv2 +from ultralytics import YOLO + +model = YOLO("yolo11n.pt") +target_class = "person" + +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Could not open the webcam. Check that one is connected, that no other " + "app is using it, and that this program has camera permission.") +else: + print("Webcam opened. Press 'q' in the video window to quit.") + while True: + ok, frame = cap.read() + if not ok: + print("Lost the camera feed. Stopping.") + break + + result = model(frame, verbose=False)[0] + count = sum(1 for box in result.boxes if model.names[int(box.cls)] == target_class) + + annotated = result.plot() + cv2.putText(annotated, f"{target_class}s: {count}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) + cv2.imshow("Webcam Object Counter (press q to quit)", annotated) + + if cv2.waitKey(1) & 0xFF == ord("q"): + break + + cap.release() + cv2.destroyAllWindows() +``` + +```bash +uv run python detect_webcam.py +``` + +`cv2.VideoCapture(0)` ouvre ta caméra par défaut de la même manière que `VideoCapture("some_file.mp4")` a ouvert un fichier à l'Étape 3 — même boucle `.read()`, même forme `(ok, frame)`. Les deux différences importantes : `.isOpened()` est vérifiée *en amont* ici, puisque « pas de caméra web disponible » est un échec réel et courant qui devrait produire un message clair plutôt qu'un crash déroutant au fond de la boucle ; et une fois en cours, le passage de `ok` à `False` en pleine boucle signifie que la connexion de la caméra a été perdue (débranchée, permission révoquée), pas « fin atteinte », puisqu'une caméra en direct n'a pas de fin. `cv2.imshow` ouvre une fenêtre en direct — une vraie fenêtre GUI, donc ce script ne produira pas de sortie visible dans un simple terminal distant sans affichage. + +**✅ Liste de vérification** + + +Une fenêtre s'ouvre montrant ton flux de caméra en direct avec des cadres englobants et un comptage en cours dessinés dessus. +Tenir un nombre différent de l'objet cible (p. ex. toi-même, puis toi et une seconde personne) change le comptage affiché/à l'écran en conséquence. +Débrancher ou couvrir la caméra en pleine exécution produit le message « flux de caméra perdu », pas un blocage silencieux. +Appuyer sur « q » ferme la fenêtre proprement au lieu de nécessiter un forçage à quitter. + + +**🤔 Question(s) socratique(s)** + +Les Étapes 3 et 4 utilisent `if not ok: break` au même endroit exact du code, mais cette ligne signifie quelque chose de différent dans chacune (« fin de fichier » vs. « problème de caméra »). Pourquoi vaut-il la peine d'écrire un message distinct pour chaque cas dans du code réel, plutôt que de traiter les deux comme la même erreur générique ? + +## ⚠️ Pièges courants + +- **Permission de caméra web refusée.** macOS et Windows demandent l'accès à la caméra la première fois qu'une application essaie de l'utiliser — si tu as rejeté cette invite (ou si elle est apparue derrière une autre fenêtre), `cv2.VideoCapture(0).isOpened()` renverra `False` même avec une caméra parfaitement fonctionnelle. Vérifie les paramètres de confidentialité de la caméra de ton système d'exploitation pour ton application de terminal ou ton interpréteur Python spécifiquement. +- **La première exécution est lente et nécessite une connexion internet.** `ultralytics` télécharge `yolo11n.pt` depuis les serveurs d'Ultralytics la première fois que tu construis `YOLO(...)` — ensuite il est mis en cache localement (typiquement sous `~/.cache` ou le répertoire courant) et chaque exécution ultérieure est entièrement hors ligne. Si la toute première exécution semble bloquée, elle est probablement encore en train de télécharger, pas coincée. +- **Confondre « aucun objet détecté » avec « la caméra ne fonctionne pas ».** Ces deux cas se ressemblent à première vue — un comptage vide dans les deux cas — mais ils ont des correctifs complètement différents. Vérifie `cap.isOpened()` et si `cv2.imshow` affiche une image en direct *avant* de t'inquiéter de savoir pourquoi le comptage est nul ; un flux fonctionnel avec un comptage réellement vide (rien ne correspondant à ta classe cible n'est dans le cadre) n'est pas un bug. +- **Décalages d'index de caméra sur les machines avec plus d'une caméra.** `VideoCapture(0)` ouvre la caméra que ton système d'exploitation considère comme par défaut, qui n'est pas toujours celle que tu attends sur un ordinateur portable avec une caméra web externe branchée — essaie `1`, `2`, etc. si `0` ouvre la mauvaise. + +## Ce que tu viens de construire + +Un vrai pipeline de vision par ordinateur fonctionnel : charge un modèle pré-entraîné, exécute-le sur des pixels au lieu de lignes ou de texte, et transforme sa sortie brute (cadres, indices de classe, scores de confiance) en quelque chose qu'une personne veut réellement — un comptage en direct d'un type spécifique d'objet. La même forme en trois étapes (détection par image → filtrer vers une classe → boucler sur les images) passe à l'échelle d'une seule photo à un flux de caméra authentiquement en direct avec seulement la source d'entrée qui change. + +:::tip[Cela se généralise au-delà de « compter des objets »] +Tout ici — un détecteur pré-entraîné, une boucle sur les images, un comptage en cours — est aussi l'épine dorsale de choses comme les capteurs de comptage de personnes aux entrées de magasins, les caméras basiques de comptage de trafic, et les compteurs d'espèces par pièges photographiques de faune sauvage. La logique de comptage de l'Étape 2 est délibérément simple (pas de suivi d'objets entre les images, donc une personne immobile pendant dix images est comptée dans les dix), ce qui est une simplification honnête, pas un bug caché — voir la section « Où aller à partir d'ici » pour ce que les systèmes réels ajoutent par-dessus. +::: + +## Où aller à partir d'ici + +- **Suivi d'objets, pas seulement détection.** La question socratique de l'Étape 3 pointe la vraie lacune : ce projet compte des objets *par image*, pas des objets distincts *à travers* une vidéo. Des bibliothèques comme le mode de suivi intégré d'`ultralytics` lui-même (`model.track(...)`, utilisant des algorithmes comme ByteTrack) assignent un ID persistant à chaque objet à travers les images, de sorte que « combien de personnes *distinctes* ont traversé le cadre » devient répondable au lieu de juste « combien sont dans le cadre en ce moment ». +- **Un modèle plus grand et plus précis.** `yolo11n.pt` (« n » pour nano) échange un peu de précision contre de la vitesse et de la taille. `ultralytics` fournit des points de contrôle plus grands (`yolo11s.pt`, `yolo11m.pt` et plus) qui détectent plus fiablement, surtout les objets petits ou partiellement masqués, au prix de plus de calcul par image — cela vaut la peine d'essayer si les comptages en direct de l'Étape 4 semblent peu fiables sur ta configuration particulière. +- **Une classe personnalisée, pas seulement les 80 de COCO.** YOLO11n ne reconnaît que ce sur quoi il a été entraîné. Ajuster finement un modèle YOLO sur tes propres images étiquetées (une version beaucoup plus petite de la même idée que le [projet Fine-tune a Small Language Model](/docs/projects/finetune-llm-unsloth)) te permet de compter quelque chose que COCO n'a jamais inclus — un produit spécifique sur une étagère, un outil spécifique, tout ce dont tu peux étiqueter quelques centaines d'exemples. + +## 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 en dehors du navigateur. 🎓 + + diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md b/i18n/fr/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md new file mode 100644 index 0000000..853c2fb --- /dev/null +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/wordle-clone/index.md @@ -0,0 +1,332 @@ +--- +id: wordle-clone +title: "Construire un Clone de Wordle" +sidebar_label: "Clone de Wordle" +slug: /projects/wordle-clone +description: "Construis un vrai jeu Wordle de terminal de zéro : un retour correct vert/jaune/gris par essai (y compris le bug classique des lettres répétées), une liste de mots personnalisée, et un suivi de statistiques persistant entre les sessions." +--- + +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 Clone de Wordle + + + + + +Ce projet ne suppose que les bases de niveau Python 101 — fonctions, listes, dictionnaires, boucles, lire et écrire un fichier. Pas de pandas, pas de clé API, pas de GPU, aucun service externe d'aucune sorte — juste un terminal, une liste de mots, et une logique qui est plus délicate à bien faire qu'elle n'en a l'air. C'est ce qui en fait un excellent Projet du Monde Réel *plus précoce* à essayer, même avant certains de ceux orientés pandas ou IA : tout ce dont tu as besoin, c'est de ce que Python 101 t'a déjà donné, appliqué à quelque chose de réellement amusant à jouer ensuite. + +C'est optionnel et non noté. Voir [Projets du monde réel](/docs/projects) pour la liste complète et croissante. + +## 🎯 Ce que tu vas faire + +1. Implémenter la logique centrale de retour d'essai — comparer un essai à un mot cible et produire une marque verte/jaune/grise par lettre, en gérant correctement les lettres répétées (le bug de logique classique de Wordle). +2. Construire une boucle de jeu interactive appuyée sur une vraie liste de mots, donnant au joueur 6 essais. +3. Valider les essais contre la liste de mots et donner un retour clair quand un essai est rejeté. +4. Ajouter un suivi de statistiques persistant — taux de victoires, série en cours, et une distribution du nombre d'essais — sauvegardé dans un fichier JSON local pour qu'il survive entre les exécutions. + +## Où exécuter ceci + +- **En local avec `uv` (recommandé).** Ce projet n'a besoin de rien au-delà de la bibliothèque standard plus une petite bibliothèque de couleurs de terminal — un bon candidat pour réellement installer du vrai Python sur ta propre machine. La section Configuration ci-dessous le détaille, et les Étapes 1–4 suivent ce chemin. +- **GitHub Codespaces.** Ouvre [codespaces.new/abderrahim-lectures/python-data-analysis-course](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) pour un environnement de développement cloud avec Node, Python et `uv` déjà installés (voir [`.devcontainer/devcontainer.json`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/.devcontainer/devcontainer.json)) — les mêmes commandes ci-dessous fonctionnent depuis un onglet de navigateur, sans aucune installation locale. +- **Google Colab, Kaggle Notebooks, ou Binder.** Ce projet a besoin de zéro dépendance externe, ce qui en fait un excellent choix de notebook dans un sens — mais l'invite `input()` d'un notebook est un peu différente d'un vrai terminal interactif : pas de tuiles colorées redessinées en place sur une seule ligne, et (sur Colab/Kaggle) les fichiers locaux d'une session ne survivent pas de façon fiable entre des visites séparées, ce qui va à l'encontre de la partie « les statistiques persistent entre les sessions » de ce projet. [`notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/wordle-clone/notebook.ipynb) est toujours une vraie version jouable — ça vaut le coup d'essayer — sache juste que l'expérience complète (tuiles colorées dans le terminal, statistiques qui persistent entre des jours de jeu séparés) est vraiment quelque chose à « exécuter en local ». + + [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/wordle-clone/notebook.ipynb) + [![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/wordle-clone/notebook.ipynb) + [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fwordle-clone%2Fnotebook.ipynb) + + {/* Badges point at this PR's branch; will point at `main` once merged. */} + +## Configuration + +`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 +``` + +Puis configure le projet : + +```bash +uv init wordle-clone +cd wordle-clone +uv add rich +``` + +`rich` est la seule dépendance tierce dont tout ce projet a besoin, et elle sert uniquement à la sortie colorée du terminal (tuiles vertes/jaunes/grises) — chaque morceau de vraie logique de jeu ci-dessous est du Python de bibliothèque standard pur. Pas de clé API, pas d'inscription, rien à configurer avant de pouvoir exécuter une seule ligne de code. + +## Étape 1 : Note un essai contre le mot cible + +Commence par la pièce qui est facile à bien faire *presque* et satisfaisante à bien faire *vraiment* : étant donné un essai de 5 lettres et un mot cible de 5 lettres, produis une marque par lettre — vert si cette lettre est à la bonne position, jaune si elle est dans le mot mais à la mauvaise position, gris sinon. + +Un premier essai tend à ressembler à ça, en vérifiant chaque lettre devinée indépendamment : + +```python +# A tempting first version — has a bug, keep reading +def score_guess_naive(guess: str, target: str) -> list[str]: + marks = [] + for i, letter in enumerate(guess): + if letter == target[i]: + marks.append("G") + elif letter in target: + marks.append("Y") + else: + marks.append("X") + return marks +``` + +Essaie-le sur `guess = "SPEED"`, `target = "ERASE"`. Le mot cible contient exactement **un** `E`. La version naïve vérifie chaque lettre devinée contre la chaîne cible entière indépendamment — donc *les deux* `E` de `SPEED` sont vérifiés contre `"E" in target`, qui est `True` les deux fois, et les deux sont marqués en jaune. C'est faux : le vrai Wordle n'attribuerait jamais deux `E` jaunes dans un essai quand le mot cible ne contient qu'un seul `E` — un `E` deviné mérite une marque, l'autre n'a plus de lettre correspondante restante pour en justifier une. + +La correction est un algorithme en deux passes : + +```python +from collections import Counter + +WORD_LENGTH = 5 + +def score_guess(guess: str, target: str) -> list[str]: + guess, target = guess.upper(), target.upper() + marks = ["X"] * WORD_LENGTH + + # Pass 1: greens, and tally which target letters are still "available" + # (i.e. not already accounted for by a green) for the yellow pass. + remaining = Counter() + for i, (g, t) in enumerate(zip(guess, target)): + if g == t: + marks[i] = "G" + else: + remaining[t] += 1 + + # Pass 2: yellows, consuming from that same pool of remaining letters + # so a letter can never be flagged more times than it truly occurs. + for i, g in enumerate(guess): + if marks[i] == "G": + continue + if remaining[g] > 0: + marks[i] = "Y" + remaining[g] -= 1 + # else stays "X" + + return marks +``` + +La passe 1 marque chaque correspondance de position exacte en vert, et compte séparément (dans `remaining`) combien de copies de chaque lettre cible *non verte* sont encore « à prendre ». La passe 2 parcourt ensuite l'essai de nouveau : toute lettre pas déjà verte ne reçoit une marque jaune que si `remaining` a encore une copie non réclamée de celle-ci — et en réclamer une décrémente le compte, donc une seconde copie devinée de la même lettre n'obtiendra pas aussi du jaune sauf si le mot cible a vraiment une seconde copie aussi. + +Exécute-le sur le cas délicat : + +```python +print(score_guess("SPEED", "ERASE")) # ['Y', 'X', 'Y', 'Y', 'X'] +``` + +Un `E` (position 0) est jaune, l'autre (position 3) est aussi jaune parce que `ERASE` a vraiment deux `E` — mais un essai comme `"ELITE"` contre un mot cible avec un seul `E` donnerait correctement au *deuxième* `E` un gris, pas un jaune. + +**✅ Liste de vérification** + + +`score_guess("CRANE", "CRANE")` returns all greens. +`score_guess("SPEED", "ERASE")` returns exactly two yellow `E`s, not more. +A guess and target that share zero letters returns all grays. +You've tried a case where the *guess* repeats a letter but the target only has one copy, and confirmed only one mark comes back non-gray. + + +**🤔 Question(s) socratique(s)** + +Essaie le mot cible `"LLAMA"` et l'essai `"ALLOY"` à la main avant d'exécuter le code : `LLAMA` a deux `L` et deux `A`. Parcours les deux passes toi-même — quelles lettres finissent en vert, lesquelles en jaune, et lesquelles en gris ? Puis vérifie ta réponse contre `score_guess`. Si tu t'es trompé sur papier, où exactement ton modèle mental a-t-il divergé de l'algorithme en deux passes ? + +## Étape 2 : Construis la boucle de jeu + +Avec un scoring solide, enveloppe-le dans un vrai jeu : choisis un mot cible aléatoire dans une liste de mots, donne au joueur 6 essais, et arrête dès qu'il a les cinq verts. + +```python +import random + +MAX_GUESSES = 6 + +def load_words(path="words.txt") -> list[str]: + with open(path) as f: + return [w.strip().upper() for w in f if w.strip()] + +def play_round(words: list[str]) -> tuple[bool, int]: + target = random.choice(words) + for attempt in range(1, MAX_GUESSES + 1): + guess = input(f"Guess {attempt}/{MAX_GUESSES}: ").strip().upper() + marks = score_guess(guess, target) + print(" ".join(f"{l}:{m}" for l, m in zip(guess, marks))) + if all(m == "G" for m in marks): + print(f"You got it in {attempt}!") + return True, attempt + print(f"Out of guesses. The word was {target}.") + return False, MAX_GUESSES +``` + +`words.txt` est un simple fichier texte, un mot par ligne — le vrai exemple inclut une liste d'environ 540 mots anglais courants de 5 lettres exactement pour ce but. Une *liste* de mots comme celle-ci (juste des faits sur quelles chaînes sont des mots anglais, sans expression créative) est libre d'utilisation et de redistribution, contrairement à copier, disons, les vraies définitions d'un dictionnaire. + +**✅ Liste de vérification** + + +Each round picks a genuinely random target from the word list (print it temporarily to confirm, then remove the print — no spoilers once you trust it). +The loop stops immediately once all five marks are green, even before 6 guesses are used. +After exactly 6 wrong guesses, the loop ends and reveals the target. + + +**🤔 Question(s) socratique(s)** + +Si `random.choice(words)` est appelé une fois par manche depuis l'intérieur de `play_round`, et que tu appelles `play_round` dans une boucle pour laisser quelqu'un rejouer, est-ce que le mot cible va vraiment changer entre les manches ? Que se passerait-il si tu calculais accidentellement `target` une fois *à l'extérieur* de la boucle à la place ? + +## Étape 3 : Valide les essais contre la liste de mots + +Le vrai Wordle ne te laisse pas deviner `"ZZZZZ"` — chaque essai doit être un vrai mot de son dictionnaire. Ajoute cette vérification avant de noter : + +```python +def read_guess(word_set: set[str]) -> str: + while True: + raw = input(f"Guess ({WORD_LENGTH} letters): ").strip().upper() + if len(raw) != WORD_LENGTH or not raw.isalpha(): + print(f" Please enter exactly {WORD_LENGTH} letters.") + continue + if raw not in word_set: + print(f" '{raw}' isn't in the word list — try a real word.") + continue + return raw +``` + +Utiliser un `set` ici au lieu de vérifier `raw in words` contre la liste directement compte plus qu'il n'y paraît : les vérifications d'appartenance d'une liste balayent chaque entrée une par une, alors qu'une vérification de set est quasi instantanée quel que soit le nombre de mots qu'il contient — une habitude petite mais authentiquement bonne pour toute vérification « est-ce que cette valeur est dans une grande collection ». + +:::tip[Rejette les mauvaises saisies tôt, pas en plein jeu] +Valider la *forme* de l'essai (5 lettres, alphabétique) avant de vérifier la liste de mots attrape les fautes de frappe les plus courantes avec la vérification la moins chère d'abord — ça ne sert à rien de chercher `"crane5"` dans un set de 540 mots quand une vérification `len()` et `.isalpha()` te dit déjà qu'il est malformé. +::: + +**✅ Liste de vérification** + + +Guessing a non-word (e.g. `"ZZZZZ"`) prints a clear rejection message and re-prompts, without consuming one of the 6 tries. +Guessing something that isn't 5 letters (too short, too long, contains a digit) is also rejected before it ever reaches the word-list check. +A valid, in-list guess is accepted immediately, lowercase or uppercase. + + +**🤔 Question(s) socratique(s)** + +Pourquoi est-il important que `read_guess` redemande sur un mauvais essai *à l'intérieur de sa propre boucle*, plutôt que de retourner une valeur sentinelle comme `None` pour que l'appelant (`play_round`) la gère ? Qu'est-ce qui irait mal avec le comptage d'essais de l'Étape 2 si un essai invalide était autorisé à consommer l'un des 6 essais ? + +## Étape 4 : Ajoute un suivi de statistiques persistant + +La dernière pièce : se souvenir de la performance du joueur, à travers des exécutions séparées du programme, pas juste au sein d'une session. Ça signifie écrire dans un fichier sur le disque. + +```python +import json +from pathlib import Path + +STATS_FILE = Path("stats.json") + +DEFAULT_STATS = { + "played": 0, + "wins": 0, + "current_streak": 0, + "max_streak": 0, + "guess_distribution": {str(n): 0 for n in range(1, MAX_GUESSES + 1)}, +} + +def load_stats() -> dict: + if not STATS_FILE.exists(): + return json.loads(json.dumps(DEFAULT_STATS)) # a fresh copy + with STATS_FILE.open() as f: + return json.load(f) + +def save_stats(stats: dict) -> None: + with STATS_FILE.open("w") as f: + json.dump(stats, f, indent=2) + +def record_result(stats: dict, won: bool, guesses_used: int) -> dict: + stats["played"] += 1 + if won: + stats["wins"] += 1 + stats["current_streak"] += 1 + stats["max_streak"] = max(stats["max_streak"], stats["current_streak"]) + stats["guess_distribution"][str(guesses_used)] += 1 + else: + stats["current_streak"] = 0 + return stats +``` + +`load_stats` gère la toute première exécution avec élégance — aucun fichier n'existe encore, donc il renvoie un nouvel ensemble de valeurs par défaut à zéro plutôt que de planter sur un fichier manquant. Chaque autre exécution charge ce qui a été sauvegardé la dernière fois. `record_result` n'ajoute à `guess_distribution` qu'en cas de victoire — une défaite n'a pas de valeur « d'essais utilisés pour gagner » significative, comme l'écran de statistiques du vrai Wordle lui-même. + +La boucle complète du jeu assemble tout : charge les statistiques une fois au démarrage, mets-les à jour et sauvegarde-les après chaque manche. + +```python +words = load_words() +stats = load_stats() + +while True: + won, attempts = play_round(words) + stats = record_result(stats, won, attempts) + save_stats(stats) + print(f"Played: {stats['played']} Win rate: {stats['wins']/stats['played']:.0%} " + f"Streak: {stats['current_streak']}") + if input("Play again? [y/N] ").strip().lower() != "y": + break +``` + +:::tip[Sauvegarde après chaque manche, pas seulement à la sortie] +Appeler `save_stats(stats)` juste après `record_result`, à chaque manche, signifie qu'un programme interrompu (terminal fermé, `Ctrl+C`, crash) ne perd au pire que le résultat de la manche *courante* — jamais la progression de toute la session. Sauvegarder une seule fois à la toute fin du programme jetterait tout si le joueur quitte en pleine session au lieu de passer par l'invite « rejouer ? ». +::: + +**✅ Liste de vérification** + + +Quitting the program and restarting it shows the same `played`/`wins`/streak numbers as before you quit, loaded from `stats.json`. +Winning in, say, 3 guesses increments `guess_distribution["3"]` specifically, not some other key. +Losing a round resets `current_streak` to 0 but does not touch `guess_distribution` or `max_streak`. +Deleting `stats.json` and rerunning the program doesn't crash — it starts a fresh, zeroed stats file instead. + + +**🤔 Question(s) socratique(s)** + +`max_streak` est calculé comme `max(stats["max_streak"], stats["current_streak"])` après chaque victoire, plutôt que d'être seulement mis à jour quand le *jeu* se termine. Pourquoi le mettre à jour après chaque victoire (au lieu d'essayer de le calculer plus tard depuis l'historique) suit-il correctement la meilleure série jamais atteinte, même si le joueur est encore sur sa meilleure série en ce moment et n'a pas encore perdu ? + +## ⚠️ Pièges courants + +- **Le bug des lettres répétées (Étape 1).** De loin l'erreur la plus courante : vérifier `letter in target` indépendamment pour chaque lettre devinée, sans suivre quelles copies d'une lettre répétée ont déjà été « réclamées ». Ça attribue trop de marques jaunes dès que l'essai ou le mot cible répète une lettre. Utilise toujours l'approche en deux passes qui consomme les copies — d'abord les verts, puis les jaunes contre un pool de lettres cibles *restantes*. +- **Des essais qui ne sont pas de vrais mots.** Sans valider contre une liste de mots (Étape 3), les joueurs peuvent deviner `"AEIOU"` ou tout autre non-mot purement pour sonder quelles lettres sont dans le mot cible — une stratégie que le vrai Wordle bloque explicitement en exigeant que chaque essai soit un mot du dictionnaire. +- **Sensibilité à la casse.** `"crane" == "CRANE"` est `False` en Python. Normalise chaque essai et chaque mot cible au même cas (ce projet utilise `.upper()` partout) dès qu'ils entrent dans ton code, sinon les comparaisons échoueront silencieusement pour des essais parfaitement valides. +- **Perdre les statistiques sur un crash.** N'écrire `stats.json` qu'une seule fois à la sortie du programme signifie que tout crash, `Ctrl+C`, ou terminal fermé perd la progression de toute cette session. Sauvegarde après chaque manche à la place (voir l'astuce à l'Étape 4). +- **Un fichier de statistiques d'une ancienne version de ton code.** Si tu ajoutes un nouveau champ à `DEFAULT_STATS` plus tard, `load_stats` tel qu'écrit ci-dessus chargera volontiers un `stats.json` *ancien* auquel manque ce champ, puis plantera la première fois que ton code essaiera de le lire. Ça vaut le coup de le gérer défensivement (vois comment `examples/wordle-clone/stats.py` fusionne les données chargées sur une copie neuve des valeurs par défaut) si tu prévois de continuer à peaufiner le schéma des statistiques. + +## Ce que tu viens de construire + +Un vrai clone de Wordle : une logique de retour d'essai correcte (y compris le cas limite des lettres répétées qui fait trébucher beaucoup de premiers essais), une boucle de jeu interactive appuyée sur une vraie liste de mots avec une validation d'essai adéquate, et des statistiques qui persistent authentiquement entre des exécutions séparées du programme — pas seulement au sein d'une session. Rien de tout ça n'avait besoin de plus que la bibliothèque standard et une petite bibliothèque de couleurs, ce qui vaut la peine d'être noté : un projet peut être substantiel et réellement amusant sans avoir besoin d'une clé API, d'un framework, ou d'un service cloud. + +:::tip[Vérifie la logique délicate avec des cas de test, pas seulement en jouant] +C'est facile de jouer quelques manches, de voir une sortie d'apparence raisonnable, et de supposer que la logique de scoring est correcte — mais le bug des lettres répétées n'apparaît spécifiquement que sur des essais ou des mots cibles avec des lettres répétées, qui ne se présentent pas à chaque manche que tu joues à la main par hasard. Écrire une poignée de cas de test explicites (comme l'exemple `SPEED`/`ERASE` de l'Étape 1) qui ciblent spécifiquement ce cas limite attrape des bugs qu'un test par le jeu occasionnel peut manquer complètement. +::: + +## Où aller à partir d'ici + +- **Mode difficile.** Le mode difficile du vrai Wordle exige que chaque essai suivant réutilise tout vert/jaune déjà révélé — faire respecter ça signifie suivre les contraintes connues à travers les essais au sein d'une manche, pas juste noter un essai isolément. +- **Un système d'indices.** Révèle la position correcte d'une lettre non devinée au hasard sur demande, au prix de compter contre le total d'essais du joueur (ou un autre compromis que tu conçois). +- **Multijoueur ou un mot quotidien partagé.** Le vrai Wordle donne notoirement à tout le monde le même mot chaque jour. Dériver le mot cible du jour de façon déterministe depuis la date (ex. hacher la chaîne de date pour choisir un index dans la liste de mots) laisserait chaque joueur voir le même mot sans serveur — un bel exercice de hasard déterministe. +- **Un solveur simple, comme objectif ambitieux.** Étant donné les marques retournées jusqu'ici, filtre la liste de mots pour ne garder que les mots toujours cohérents avec chaque contrainte révélée — un renversement amusant de la logique de jeu que tu viens d'écrire, et un bon exercice du même raisonnement sur les lettres répétées de l'Étape 1, appliqué dans la direction opposée. + +## 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. 🎓 + +