From aaeafad6cb7a293b99e13c7cf8006b9aa465cf43 Mon Sep 17 00:00:00 2001 From: Abderrahim Adrabi <184391033+abderrahim-lectures@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:26:08 +0100 Subject: [PATCH] i18n: translate Meeting-Notes Summarizer project into ar/es/fr Co-Authored-By: Claude Sonnet 5 --- i18n/ar/code.json | 8 + .../current/projects/index.mdx | 6 + .../meeting-notes-summarizer/_category_.json | 4 + .../meeting-notes-summarizer/index.md | 495 ++++++++++++++++++ i18n/es/code.json | 8 + .../current/projects/index.mdx | 6 + .../meeting-notes-summarizer/_category_.json | 4 + .../meeting-notes-summarizer/index.md | 495 ++++++++++++++++++ i18n/fr/code.json | 8 + .../current/projects/index.mdx | 6 + .../meeting-notes-summarizer/_category_.json | 4 + .../meeting-notes-summarizer/index.md | 495 ++++++++++++++++++ 12 files changed, 1539 insertions(+) create mode 100644 i18n/ar/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json create mode 100644 i18n/ar/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md create mode 100644 i18n/es/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json create mode 100644 i18n/es/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md create mode 100644 i18n/fr/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json create mode 100644 i18n/fr/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md diff --git a/i18n/ar/code.json b/i18n/ar/code.json index 7c28451..216281a 100644 --- a/i18n/ar/code.json +++ b/i18n/ar/code.json @@ -893,5 +893,13 @@ "homepage.projects.mcpSqliteServer.summary": { "message": "ابنِ خادم MCP يعرض قاعدة بيانات SQLite محلية، وشاهد عميل نموذج لغوي يكتب وينفّذ SQL الخاص به للإجابة عن أسئلة بلغة طبيعية حولها.", "description": "Homepage project card summary" + }, + "homepage.projects.meetingNotesSummarizer.title": { + "message": "بناء مُلخِّص ملاحظات الاجتماعات", + "description": "Homepage project card title" + }, + "homepage.projects.meetingNotesSummarizer.summary": { + "message": "حوّل نص اجتماع خام إلى قرارات وعناصر عمل وأسئلة مفتوحة مُهيكَلة، باستخدام نموذج لغوي من مستوى مجاني وprompt استخلاص JSON مصمَّم بعناية.", + "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 ccddcac..c79b9cf 100644 --- a/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -137,5 +137,11 @@ import {mergeProjectMeta} from '@site/src/data/projects'; summary: 'ابنِ خادم MCP يعرض قاعدة بيانات SQLite محلية، وشاهد عميل نموذج لغوي يكتب وينفّذ SQL الخاص به للإجابة عن أسئلة بلغة طبيعية حولها.', }, + { + id: 'meeting-notes-summarizer', + title: 'بناء مُلخِّص ملاحظات الاجتماعات', + summary: + 'حوّل نص اجتماع خام إلى قرارات وعناصر عمل وأسئلة مفتوحة مُهيكَلة، باستخدام نموذج لغوي من مستوى مجاني وprompt استخلاص JSON مصمَّم بعناية.', + }, ])} /> diff --git a/i18n/ar/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json b/i18n/ar/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json new file mode 100644 index 0000000..0bf9ea8 --- /dev/null +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "مُلخِّص ملاحظات الاجتماعات", + "position": 13 +} diff --git a/i18n/ar/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md b/i18n/ar/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md new file mode 100644 index 0000000..a69cf1f --- /dev/null +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md @@ -0,0 +1,495 @@ +--- +id: meeting-notes-summarizer +title: "ابنِ مُلخِّص ملاحظات الاجتماعات" +sidebar_label: "مُلخِّص ملاحظات الاجتماعات" +slug: /projects/meeting-notes-summarizer +description: "تخرَّج من بيئة اللعب داخل المتصفح إلى Python حقيقية: اكتب سكربتًا يحوّل نص اجتماع خام إلى ملخص مُهيكَل — قرارات وعناصر عمل وأسئلة مفتوحة — باستخدام نموذج لغوي من مستوى مجاني وتصميم prompt دقيق." +--- + +import ProjectProgressCheckbox from '@site/src/components/ProjectProgressCheckbox'; +import ProjectPublishedDate from '@site/src/components/ProjectPublishedDate'; +import ProjectGreeting from '@site/src/components/ProjectGreeting'; +import {StepChecklist, StepChecklistItem} from '@site/src/components/StepChecklist'; + +# 🌍 ابنِ مُلخِّص ملاحظات الاجتماعات + + + + + +كل شيء في الدورة حتى الآن عمل في بيئة لعب معزولة داخل المتصفح — حتى تتمكن من البدء بكتابة Python من اليوم الأول بلا أي إعداد. هذا المشروع هو خطوة التخرّج: ثبّت Python فعليًا على جهازك الخاص، ثم استخدمها لبناء أداة تحل مشكلة حقيقية مزعجة فعلاً من العالم الواقعي — تحويل جدار من نص اجتماع خام إلى ملخص قصير مُهيكَل: ما الذي أُقرَّ، ومن المكلّف بماذا، وما الذي لا يزال عالقًا بلا حل. يفترض هذا Python بمستوى 101؛ لا شيء من تحليل البيانات مطلوب. + +هذا اختياري وغير مُقيَّم. راجع [مشاريع من العالم الحقيقي](/docs/projects) للاطلاع على القائمة الكاملة والنامية. + +## 🎯 ما ستفعله + +1. ثبّت `uv`، أداة سريعة وحديثة لإدارة Python نفسها واعتماديات مشروعك. +2. احصل على مفتاح API مجاني لنموذج لغوي — أيٌّ من ستة مزوّدين يعمل. +3. حمّل نص اجتماع حقيقي (يُشحن هذا المشروع بثلاث عيّنات واقعية، فيعمل بلا أي إعداد). +4. صمّم prompt يطلب من النموذج إعادة **JSON مُهيكَل**، لا نثرًا منسابًا بحرية — المهارة الجوهرية القابلة للنقل في هذا المشروع. +5. استدعِ النموذج، ثم حلّل وتحقّق من استجابة JSON الخاصة به — متعاملًا مع الحالة التي تعود فيها مشوّهة قليلًا، وهو ما يحدث أكثر مما تود. +6. صُغ النتيجة المُهيكَلة كـMarkdown قابل للقراءة وملف `.json` معًا، وشغّل الأمر كله من البداية إلى النهاية على نص اجتماع حقيقي. + +## أين تُشغّل هذا + +**محليًا باستخدام `uv`** هو المسار الذي تتبعه خطوات هذا الدرس، والموصى به — إنه Python فعلي يعمل على جهازك الخاص، نفس حركة "التخرّج إلى Python حقيقية" كما في كل مشروع آخر في هذا القسم. يشرح قسم الإعداد أدناه كيفية تثبيته. + +**GitHub Codespaces** بديل بلا أي إعداد إن كنت تفضّل عدم تثبيت أي شيء محليًا بعد: افتح [مستودع الدورة كاملًا في Codespace مجاني](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node وPython و`uv` مثبّتة مسبقًا، حسب ملف `.devcontainer/devcontainer.json` الخاص بالمستودع) وشغّل نفس أوامر `uv` تمامًا من طرفية في تبويب متصفحك. + +**Google Colab وKaggle Notebooks أو Binder** تعمل جيدًا أيضًا، وهي خيارات جيدة فعلًا هنا — هذا المشروع سكربت خفيف يُطلق حفنة من استدعاءات واجهة برمجية، لا شيء يحتاج GPU أو بنية مشروع حقيقية ليكون مفيدًا. نسخة دفتر ملاحظات جاهزة للتشغيل تُشحن مع هذا المشروع — انقر على شارة أدناه لفتحها، بلا أي إعداد محلي — أو أنشئ دفتر ملاحظاتك الخاص، وشغّل `!pip install openai python-dotenv` في خلية، والصق السكربتات أدناه كخلايا، واضبط مفتاح API الخاص بك بسر دفتر ملاحظات (Colab) أو متغيّر بيئة بدلًا من ملف `.env`. + +{/* TODO: update these badge links to point at main once this PR merges */} +[![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/meeting-notes-summarizer/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/meeting-notes-summarizer/notebook.ipynb) +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fmeeting-notes-summarizer%2Fnotebook.ipynb) + +## الإعداد + +كل ما تحتاجه قبل كتابة أي كود تلخيص — تثبيت `uv`، وإنشاء المشروع، والحصول على مفتاح API مجاني، وضبطه كمتغيّر بيئة — يعيش في هذا القسم الواحد، لذا عليك فعله مرة واحدة فقط. + +### 1. ثبّت `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 +``` + +يمكن لـ`uv` أيضًا جلب وإدارة مفسّر Python حقيقي مباشرة: + +```bash +uv python install 3.12 +``` + +### 2. أنشئ المشروع + +```bash +uv init meeting-notes-summarizer +cd meeting-notes-summarizer +uv add openai python-dotenv +``` + +ينشئ `uv init` مشروعًا صغيرًا (ملف `pyproject.toml` يتتبع اعتمادياتك) ويُثبّت `uv add` الحزم في بيئة معزولة تلقائيًا — بلا إعداد بيئة افتراضية يدوي. `openai` يُستخدم هنا لأن عدة مزوّدين من مستوى مجاني، بمن فيهم الافتراضي المقترح، يعرضون واجهة برمجية متوافقة مع OpenAI، لذا تعمل مكتبة العميل الواحدة عبرهم جميعًا، فقط موجَّهة إلى `base_url` مختلف. يتيح لك `python-dotenv` الاحتفاظ بمفتاح API الخاص بك في ملف `.env` محلي بدلًا من تصديره (`export`) في كل جلسة. + +### 3. احصل على مفتاح API مجاني لنموذج لغوي + +**اختر أي مزوّد تفضله** — لا يتطلب أيٌّ منها بطاقة ائتمان وقت كتابة هذا النص، وهذه الدورة لا تفضّل واحدًا على آخر. + +| المزوّد | أين تحصل على مفتاح | لماذا قد تختاره | +|---|---|---| +| **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 على موقع ذلك المزوّد. + +### 4. أنشئ ملف `.env` الخاص بك + +**لا تلصق مفتاح API أبدًا مباشرة في الكود أو تُودعه في مستودع.** أنشئ ملف `.env` في مجلد مشروعك بدلًا من ذلك (وتأكد من إدراج `.env` في `.gitignore`، إلى جانب `.venv` مباشرة): + +```bash +# .env +GITHUB_TOKEN=your-key-here +``` + +:::tip[ملف `.env` أجدى من تصدير (`export`) المفتاح في كل جلسة] +يقرأ `load_dotenv()` من `python-dotenv` ملف `.env` إلى `os.environ` تلقائيًا لحظة بدء سكربتك، لذا لا يتعين عليك أبدًا تذكر تصدير (`export`) مفتاح في كل نافذة طرفية جديدة. راجع [`examples/meeting-notes-summarizer/.env.example`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/meeting-notes-summarizer) من هذه الدورة للاطلاع على قالب يغطي المزوّدين الستة جميعًا. +::: + +مع اكتمال الإعداد، كل ما يلي يتعلق بالمُلخِّص الفعلي. + +## الخطوة 1: حمّل نص اجتماع نموذجي + +أنشئ مجلد `transcripts/` وضع فيه نص اجتماع نصيًا عاديًا — أو انسخ واحدة من العيّنات الثلاث الواقعية التي تُشحن مع مثال مستودع هذا المشروع: اجتماع وقوف يومي، واجتماع تخطيط منتج، ومراجعة حادثة (انظر [`examples/meeting-notes-summarizer/sample_transcripts/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/meeting-notes-summarizer/sample_transcripts)). نص الاجتماع مجرد نص عادي مُوسوم باسم المتحدث، لا شيء أبسط من ذلك: + +```text +Maria: Let's start with the API migration. Where are we? +James: About 70% done. I should finish the auth endpoints by Friday. +Maria: Good. Can you also write the migration guide for the team? +James: Yeah, I'll own that too. +Priya: Quick question -- are we still deprecating the v1 endpoints next month? +Maria: Let's hold off on that decision until James finishes the migration. I don't want to commit to a date yet. +``` + +تحميله هو أصغر خطوة ممكنة، عمدًا: + +```python +# load_transcript.py +"""Loads a plain-text meeting transcript from disk. + +Run with: uv run python load_transcript.py transcripts/standup.txt +""" + +import sys +from pathlib import Path + + +def load_transcript(path: str) -> str: + """Reads a transcript file and returns its raw text.""" + text = Path(path).read_text(encoding="utf-8") + if not text.strip(): + raise ValueError(f"{path} is empty -- nothing to summarize.") + return text + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "transcripts/standup.txt" + transcript = load_transcript(path) + print(f"Loaded {len(transcript)} characters from {path}") + print(transcript[:200] + ("..." if len(transcript) > 200 else "")) +``` + +```bash +uv run python load_transcript.py transcripts/standup.txt +``` + +**✅ قائمة التحقق** + + +يطبع `uv run python load_transcript.py ` عدد أحرف غير صفري ومعاينة تبدو كنص اجتماع حقيقي. +تشغيله على مسار غير موجود يثير خطأ Python واضحًا بدلًا من عدم فعل أي شيء بصمت. +تشغيله على ملف فارغ يُثير `ValueError` الذي كتبته، لا خطأًا مربكًا في مرحلة لاحقة. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +- لماذا التحقق من نص الاجتماع الفارغ هنا، في الخطوة 1، بدلًا من مجرد ترك prompt فارغ يصل إلى النموذج اللغوي في خطوة لاحقة ورؤية ما يحدث؟ +- تفترض هذه الدالة أن نص الاجتماع بأكمله يسع في prompt واحد براحة. ما نص الاجتماع الحقيقي الذي سيكسر هذا الافتراض، وكيف ستعرف تقريبًا قبل تشغيله؟ + +## الخطوة 2: صمّم prompt لاستخراج مُهيكَل + +هذه هي المهارة الفعلية التي يعلّمها هذا المشروع: بدلًا من أن تطلب من نموذج ملخصًا حرّ الشكل في فقرة ("لخّص هذا الاجتماع من فضلك")، تطلب منه أن يُعيد **JSON بشكل محدد** — مخططًا تُعرّفه أنت — ليكون الناتج شيئًا يستطيع كودك الخاص تحليله وتخزينه والتصرف بناءً عليه بموثوقية. هذه نفس فكرة عقد واجهة برمجية، لكنها تُطبَّق عبر صياغة prompt بدلًا من نظام أنواع. + +مخطط هذا المشروع: ثلاث قوائم — `decisions`، و`action_items` (لكل عنصر `task` و`owner` اختياري، حينما يسمّي نص الاجتماع شخصًا فعليًا)، و`open_questions`. + +```python +# extract_prompt.py +"""Builds the structured-extraction prompt sent to the LLM. + +Imported by summarize.py (Step 3) -- not meant to be run directly. +""" + +SYSTEM_PROMPT = """You are an assistant that extracts structured information \ +from meeting transcripts. You always respond with a single JSON object and \ +nothing else -- no markdown code fences, no commentary before or after it.""" + +# The exact shape we require back. Spelling this out in the prompt itself, +# field by field, is what makes a small/free-tier model actually follow it -- +# vague instructions like "return the decisions and action items as JSON" +# produce far less consistent shapes across runs. +JSON_SCHEMA_DESCRIPTION = """Respond with a JSON object with EXACTLY these keys: + +{ + "decisions": ["short string describing one decision that was made", ...], + "action_items": [ + {"task": "short string describing the task", "owner": "person's name, or null if not stated"}, + ... + ], + "open_questions": ["short string describing one unresolved question", ...] +} + +Rules: +- Only include a decision if the transcript shows the group actually agreeing on something -- not just discussing an option. +- Only include an action item if someone (or the group) commits to doing it. +- "owner" must be null (not the string "null", not "TBD") when no specific person is named for that task. +- If a category has nothing to report, use an empty list -- never omit the key. +- Do not invent information that isn't in the transcript.""" + + +def build_prompt(transcript: str) -> list[dict]: + """Returns the chat messages list ready to send to the LLM.""" + return [ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": f"{JSON_SCHEMA_DESCRIPTION}\n\nTranscript:\n{transcript}", + }, + ] +``` + +ثلاثة أشياء تجعل تصميم هذا prompt متعمّدًا، لا مصادفة: + +1. **يُذكَر المخطط حرفيًا**، مفتاحًا بمفتاح، بشكل مثال — لا يُوصف بنثر. النماذج أكثر اتساقًا بكثير في مطابقة مثال من في استنتاج مخطط من وصف. +2. **يُسمَح صراحةً لـ`owner` بأن يكون `null`**، مع قاعدة صريحة لمتى يُستخدم. دون تلك القاعدة، تميل النماذج إلى اختلاق اسم يبدو معقولًا، أو كتابة السلسلة `"TBD"` — قيمة سيضطر كود Python الخاص بك بعدها لمعالجتها بشكل خاص إلى الأبد. +3. **يذكر prompt النظامي تنسيق المخرجات كقيد صارم** ("لا شيء آخر — لا أسوار كود markdown، لا تعليقات")، لأن الطريقة الأشيع التي يسوء بها هذا الأمر (انظر الخطوة 3) هي أن يلفّ النموذج JSON الخاص به في سور كود ```` ```json ```` بحكم العادة، حتى عندما يُقال له ألا يفعل. + +**✅ قائمة التحقق** + + +تُعيد `build_prompt(transcript)` قائمة من قاموسي رسالتين (`system`، `user`)، مع نص الاجتماع مضمّنًا فعلًا في رسالة المستخدم. +يمكنك الإشارة إلى الجملة الدقيقة في `JSON_SCHEMA_DESCRIPTION` التي تخبر النموذج ماذا يفعل عندما لا يُسمّى أي owner. +تستطيع أن تشرح، في جملة واحدة، لماذا يُكتب المخطط كمثال JSON حرفي بدلًا من وصف فقرة. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +- لو أزلت قاعدة "أدرج قرارًا فقط إذا أظهر نص الاجتماع أن المجموعة اتفقت فعلًا على شيء — لا مجرد مناقشة خيار"، فما نوع العناصر التي تعتقد أنها ستبدأ بالتسرّب إلى `decisions` في نص اجتماع مليء بالجدال ذهابًا وإيابًا؟ +- يطلب الـ prompt `owner: null` بدلًا من حذف الحقل تمامًا. لماذا قد يكون ذلك أسهل لكود Python الخاص بك في التعامل معه من مخطط يكون فيه الحقل حاضرًا أحيانًا ومفقودًا أحيانًا أخرى؟ + +## الخطوة 3: استدعِ النموذج اللغوي وحلّل استجابة JSON + +الآن أرسل الـ prompt وحوّل أي نص يعود إلى بيانات Python حقيقية — `dict` تستطيع التكرار عليه، لا سلسلة تحتاج لفحصها بالعين. هذا هو المكان الذي تنكسر فيه مشاريع الاستخراج المُهيكَل غالبًا في الممارسة: حتى الـ prompt المصمم جيدًا يحصل أحيانًا على استجابة ملفوفة في سور كود، أو بتعليق زائد، أو بفاصلة شاردة — ويُنهار استدعاء `json.loads()` ساذج على الأنواع الثلاثة جميعًا. + +```python +# summarize.py (part 1 -- LLM call + parsing) +"""Calls a free-tier LLM to extract a structured summary from a transcript, +then parses and validates the JSON it returns. + +Run with: uv run python summarize.py transcripts/standup.txt +""" + +import json +import os +import re +import sys + +from dotenv import load_dotenv +from openai import OpenAI + +from extract_prompt import build_prompt +from load_transcript import load_transcript + +load_dotenv() + +REQUIRED_KEYS = {"decisions", "action_items", "open_questions"} + + +def call_llm(transcript: str) -> str: + """Sends the structured-extraction prompt and returns the model's raw text reply.""" + client = OpenAI( + api_key=os.environ["GITHUB_TOKEN"], + base_url="https://models.github.ai/inference", + ) + response = client.chat.completions.create( + model="gpt-4o-mini", # confirm this still has a free tier before running + messages=build_prompt(transcript), + temperature=0, # deterministic-as-possible extraction, not creative writing + ) + return response.choices[0].message.content + + +def extract_json(raw_text: str) -> str: + """Strips common wrapping the model adds around JSON despite being told not to. + + Handles the two most frequent offenders: a ```json ... ``` markdown fence, + and leading/trailing prose sentences around an otherwise-valid object. + """ + text = raw_text.strip() + fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL) + if fenced: + return fenced.group(1).strip() + # No fence -- fall back to grabbing everything between the first "{" and + # the last "}", in case the model added a sentence before or after the object. + start, end = text.find("{"), text.rfind("}") + if start != -1 and end != -1 and end > start: + return text[start : end + 1] + return text + + +def parse_summary(raw_text: str) -> dict: + """Parses and validates the model's response, raising a clear error if it + doesn't match the schema after the best-effort cleanup in extract_json().""" + cleaned = extract_json(raw_text) + try: + data = json.loads(cleaned) + except json.JSONDecodeError as error: + raise ValueError( + f"Model response wasn't valid JSON even after cleanup: {error}\n" + f"Raw response was:\n{raw_text}" + ) from error + + if not isinstance(data, dict) or not REQUIRED_KEYS.issubset(data.keys()): + raise ValueError(f"Response is missing required keys {REQUIRED_KEYS}. Got: {data!r}") + + # Normalize: make sure each list field really is a list, even if the + # model returned a single object instead of a one-item list somewhere. + for key in ("decisions", "action_items", "open_questions"): + if not isinstance(data[key], list): + data[key] = [data[key]] + + return data + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "transcripts/standup.txt" + transcript = load_transcript(path) + raw = call_llm(transcript) + summary = parse_summary(raw) + print(json.dumps(summary, indent=2)) +``` + +```bash +uv run python summarize.py transcripts/standup.txt +``` + +:::tip[لا تصدّق أبدًا شكل مخرجات نموذج لغوي بشكل أعمى] +عامِل استجابة نموذج لغوي كما تعامل بيانات من واجهة برمجية غير موثوقة أو CSV مرفوع من مستخدم: تحقق منها قبل استخدامها، لا تفترضها. يتعامل `extract_json` مع مشاكل اللفّ الشائعة، وما زال `parse_summary` يُثير خطأ واضحًا ومحددًا — مع النص الخام مرفقًا — إذا لم يطابق الناتج المخطط فعلًا، بدلًا من ترك `KeyError` بعد ثلاث دوال يجعلك تتخمّن ما الذي ساء. إعادة ملخص فارغ بصمت عند فشل التحليل أسوأ من الانهيار: لن تلاحظ أبدًا أن الاستخراج توقف عن العمل بهدوء. +::: + +**✅ قائمة التحقق** + + +يطبع `uv run python summarize.py transcripts/standup.txt` JSON صالحًا وقابلًا للقراءة بكل المفاتيح الثلاثة المطلوبة. +تستطيع أن تشرح ماذا يفعل `extract_json` باستجابة ملفوفة في ```` ```json ... ``` ````، مقابل استجابة بلا أي سور كود. +تغيير `REQUIRED_KEYS` مؤقتًا ليشمل مفتاحًا تعرف أنه ليس في المخطط وإعادة التشغيل يُنتج `ValueError` واضحًا خاصًا بك، لا انهيارًا في مكان آخر. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +- الحل الاحتياطي لـ`extract_json` — التقاط كل شيء بين أول `{` وآخر `}` — سينكسر على نص اجتماع يحتوي حرفيًا أقواسًا متعرجة في كلام أحدهم (مثل اقتباس مقتطف كود). هل يمكنك التفكير في نهج أكثر متانة، حتى لو كان أكثر عملًا للتنفيذ؟ +- لماذا يُثير `parse_summary` استثناءً مع الاستجابة الخام مرفقة، بدلًا من مجرد إعادة `None` عند فشل التحليل؟ + +## الخطوة 4: صُغ النتيجة كـMarkdown قابل للقراءة + +الـ `dict` المحلَّل هو بالضبط ما تريده للحفظ في قاعدة بيانات أو للتغذية إلى سكربت آخر، لكنه ليس شيئًا يريد زميل فريق قراءته في رسالة Slack. حوّله أيضًا إلى ملخص Markdown قصير قابل للتصفّح — نفس البيانات، مُنسَّقة لإنسان بدلًا من برنامج. + +```python +# format_summary.py +"""Formats a parsed summary dict as readable Markdown. + +Imported by summarize.py (Step 5) -- not meant to be run directly. +""" + + +def format_markdown(summary: dict, source: str) -> str: + lines = [f"# Meeting Summary — {source}", ""] + + lines.append("## Decisions") + if summary["decisions"]: + lines += [f"- {d}" for d in summary["decisions"]] + else: + lines.append("_No decisions recorded._") + lines.append("") + + lines.append("## Action Items") + if summary["action_items"]: + for item in summary["action_items"]: + owner = item.get("owner") or "unassigned" + lines.append(f"- [ ] {item['task']} — **{owner}**") + else: + lines.append("_No action items recorded._") + lines.append("") + + lines.append("## Open Questions") + if summary["open_questions"]: + lines += [f"- {q}" for q in summary["open_questions"]] + else: + lines.append("_No open questions recorded._") + + return "\n".join(lines) +``` + +`item.get("owner") or "unassigned"` يقوم بعمل مزدوج: يتعامل مع `None` الحرفي (ما يطلب الـ prompt من النموذج استخدامه عندما لا يُسمّى owner) ودفاعيًا مع سلسلة فارغة أو الكلمة `"null"` التي قد تنتجها بعض النماذج الأصغر رغم التعليمات — في كلتا الحالتين، يرى القارئ "unassigned" بدلًا من فراغ أو `null` حرفي مربك. + +**✅ قائمة التحقق** + + +تُعيد `format_markdown(summary, "standup.txt")` سلسلة تبدأ بعنوان `# Meeting Summary`. +عنصر عمل بلا owner مُسمّى يظهر كـ"unassigned"، لا فراغ أو الكلمة "None". +تمرير ملخص تكون فيه كل قائمة فارغة ما زال يُنتج Markdown صالحًا وقابلًا للقراءة (أسطر `_No ... recorded._`)، لا قسمًا فارغًا أو مكسورًا. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +- تُعرض عناصر العمل كـ`- [ ] task` — صيغة مربع اختيار Markdown بتنسيق GitHub. أين قد يكون ذلك مفيدًا فعلًا مقابل زخرفيًا بحتًا، اعتمادًا على أين ينتهي هذا الملف (مشكلة GitHub، رسالة Slack، ملف نصي عادي)؟ +- لماذا بناء Markdown من الـ `dict` *المحلَّل بالفعل*، بدلًا من طلب أن يولّد النموذج اللغوي Markdown مباشرة في الخطوة 3 وتخطي هذه الخطوة؟ + +## الخطوة 5: شغّله من البداية إلى النهاية + +اربط القطع معًا: حمّل نص اجتماع، واستدعِ النموذج، وحلّل JSON وتحقّق منه، ثم اكتب ملفي `.md` و`.json` بجانب المدخل. + +```python +# summarize.py (part 2 -- appended to part 1 above) + +from pathlib import Path + +from format_summary import format_markdown + + +def summarize(path: str) -> dict: + """Runs the full pipeline for one transcript and writes both output files.""" + transcript = load_transcript(path) + raw = call_llm(transcript) + summary = parse_summary(raw) + + stem = Path(path).stem + Path(f"{stem}_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + Path(f"{stem}_summary.md").write_text(format_markdown(summary, source=path), encoding="utf-8") + + return summary + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "transcripts/standup.txt" + summary = summarize(path) + print(format_markdown(summary, source=path)) + print(f"\n(also wrote {Path(path).stem}_summary.json and {Path(path).stem}_summary.md)") +``` + +```bash +uv run python summarize.py transcripts/standup.txt +uv run python summarize.py transcripts/product_planning.txt +uv run python summarize.py transcripts/incident_review.txt +``` + +شغّله على العيّنات الثلاث جميعًا (أو نسخة [`examples/meeting-notes-summarizer/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/meeting-notes-summarizer) الأشمل من المستودع، التي تُشحن بالثلاث جاهزة) وقارن المخرجات: اجتماع الوقوف، واجتماع تخطيط، ومراجعة حادثة، كل واحد يضغط على المخطط بشكل مختلف — مراجعة الحادثة، مثلًا، تميل لإنتاج أسئلة مفتوحة أكثر بكثير من عناصر العمل. + +:::tip[حدود المعدل متوقعة، لا خلل] +يحدّ كل مستوى مجاني الطلبات في الدقيقة أو في اليوم، وكل استدعاء لـ`summarize()` هو بالضبط طلب واجهة برمجية واحد — لذا تشغيل هذا عبر عدة نصوص اجتماع متتالية قد يصادف أحيانًا خطأ `429`. ذلك هو المزوّد يطلب منك الإبطاء، لا علامة على أن شيئًا مكسور؛ انتظر العدد المقترح من الثواني وأعد التشغيل. راجع مشروع [وكيل الذكاء الاصطناعي](/docs/projects/ai-agent#التعامل-مع-حدود-المعدل) لنمط `try`/`except`-مع-إعادة-محاولة يمكنك نسخه مباشرة إن أردت لهذا التعافي تلقائيًا. +::: + +**✅ قائمة التحقق** + + +يطبع `uv run python summarize.py transcripts/standup.txt` ملخص Markdown قابلًا للقراءة ويُبلِّغ عن كتابة ملفي مخرجات. +يوجد كل من `standup_summary.json` و`standup_summary.md` بعد ذلك، وملف JSON صالح (افتحه، أو أعد تحليله بـ`json.load`). +تشغيله على نص اجتماع ثانٍ مختلف يُنتج ملخصًا يعكس فعلًا محتوى *ذلك* النص — لا نسخة من مخرجات الأول. + + +**🤔 سؤال (أسئلة) سقراطي(ة)** + +- لو سلّمك زميل فريق نص اجتماع بلا قرارات واضحة على الإطلاق — مجرد عصف ذهني مفتوح — ماذا تتوقع أن تبدو عليه `decisions`، وهل تضمن صياغة الـ prompt الخاص بك ذلك فعلًا؟ +- ماذا سينكسر لو شغّلت هذا على نص اجتماع من ساعتين و15,000 كلمة بدلًا من هذه العيّنات القصيرة؟ عند أي نقطة ستحتاج إلى استراتيجية مثل نهج التقطيع من مشروع [RAG](/docs/projects/rag-notes) بدلًا من إرسال كل شيء في prompt واحد؟ + +## ⚠️ مآزق شائعة + +- **يلفّ النموذج JSON الخاص به في سور كود markdown رغم ذلك**، حتى عندما يُقال له صراحةً ألا يفعل — خاصة في النماذج الأصغر/من مستوى مجاني. `extract_json` في الخطوة 3 يزيل هذا تلقائيًا؛ لا تتخطّه واستدعِ `json.loads()` مباشرة على الاستجابة الخام. +- **يعود `owner` كالسلسلة `"null"` أو `"TBD"` أو `"N/A"`** بدلًا من `null`/`None` حقيقي. `item.get("owner") or "unassigned"` في `format_markdown` يلتقط الحالات الكاذبة، لكن سلسلة حرفية مثل `"TBD"` ستنزلق كما هي — جدير بالترقيع صراحةً (مثل `if owner in ("null", "TBD", "N/A", ""): owner = None`) إذا رأيت حدوثه كثيرًا مع مزوّدك المختار. +- **نسيان `temperature=0`.** مهام الاستخراج تريد من نفس نص الاجتماع أن ينتج ملخصًا ثابتًا وقابلًا للتكرار — لا تنوعًا إبداعيًا بين التشغيلات. ترك الافتراضي (غالبًا `~1.0`) يجعل النتائج أقل استقرارًا بشكل ملحوظ من تشغيل لآخر، مما يجعل تصحيح الـ prompt أصعب لأنك لا تستطيع أن تعرف إن كان تغيّر المخرجات جاء من تعديلك في الـ prompt أم مجرد عشوائية. +- **حدود المعدل على مستوى LLM المجاني.** كل استدعاء لـ`summarize()` يكلّف طلبًا واحدًا من حصة مزوّدك؛ تشغيله عبر نصوص اجتماعات كثيرة بسرعة قد يطلق 429. انظر النصيحة أعلاه. + +## ما بنيته للتو + +خط أنابيب استخراج مُهيكَل صغير ومكتمل: حمّل نصًا خامًا، وصمّم prompt يثبّت مخطط مخرجات دقيقًا، واستدعِ نموذجًا لغويًا من مستوى مجاني، وحلّل وتحقّق دفاعيًا مما يعود، واعرض النتيجة لكل من الآلات (JSON) والبشر (Markdown). هذا ليس تبسيطًا لعبة — نفس الشكل تمامًا (prompt مُقيَّد بمخطط ← تحليل ← تحقق ← تراجع رشيق) هو كيف تستخرج الأنظمة الإنتاجية بيانات مُهيكَلة من السير الذاتية والفواتير وتذاكر الدعم والعقود. بدّل المخطط والـ prompt، وما زال هذا الخط يعمل. + +## إلى أين تذهب من هنا + +- وسّع المخطط بحقل `sentiment` أو `meeting_type`، أو `priority` على كل عنصر عمل — النمط (صِف الحقل في الـ prompt، تحقق منه بعد التحليل) مطابق لما بنيته بالفعل. +- جرّب إطعام النموذج نص اجتماع بصيغة مختلفة تمامًا (تصدير دردشة، ملف ترجمة مُغلقة خام `.vtt`) وانظر كم من التنظيف يحتاجه `load_transcript` قبل أن تبقى النتائج جيدة. +- اطّلع على مكتبة تحقق مخطط مثل `pydantic` لنسخة أكثر صرامة من `parse_summary` — بدلًا من فحص المفاتيح يدويًا، عرّف نموذج `Summary` مرة واحدة ودعه يتحقق (بل وحتى يفرض) الأنواع عنك، رافعًا خطأ مُهيكَلًا على أي شيء لا يناسب. +- ادمج هذا مع مشروع [وكيل الذكاء الاصطناعي](/docs/projects/ai-agent): أعطِ الوكيل أداة تستدعي `summarize()` على ملف نص اجتماع، ليقدر هو أن يقرر *متى* يلخّص كجزء من مهمة أكبر بدلًا من أن تشغّل السكربت دائمًا يدويًا. + +## شارك مشروعك مع الصف + +بنيت شيئًا فخورًا به؟ [`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 176eb7b..f5965a6 100644 --- a/i18n/es/code.json +++ b/i18n/es/code.json @@ -893,5 +893,13 @@ "homepage.projects.mcpSqliteServer.summary": { "message": "Construye un servidor MCP que expone una base de datos SQLite local, y observa a un cliente LLM escribir y ejecutar su propio SQL para responder preguntas en lenguaje natural sobre ella.", "description": "Homepage project card summary" + }, + "homepage.projects.meetingNotesSummarizer.title": { + "message": "Construye un Resumidor de Notas de Reuniones", + "description": "Homepage project card title" + }, + "homepage.projects.meetingNotesSummarizer.summary": { + "message": "Convierte una transcripción de reunión en bruto en decisiones estructuradas, elementos de acción y preguntas abiertas, usando un LLM de nivel gratuito y un prompt de extracción JSON cuidadosamente diseñado.", + "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 aa7dcfc..282cc59 100644 --- a/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -137,5 +137,11 @@ Son opcionales y no calificados. Explóralos en cualquier momento — la introdu summary: 'Construye un servidor MCP que expone una base de datos SQLite local, y observa a un cliente LLM escribir y ejecutar su propio SQL para responder preguntas en lenguaje natural sobre ella.', }, + { + id: 'meeting-notes-summarizer', + title: 'Construye un Resumidor de Notas de Reuniones', + summary: + 'Convierte una transcripción de reunión en bruto en decisiones estructuradas, elementos de acción y preguntas abiertas, usando un LLM de nivel gratuito y un prompt de extracción JSON cuidadosamente diseñado.', + }, ])} /> diff --git a/i18n/es/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json b/i18n/es/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json new file mode 100644 index 0000000..9aaadc7 --- /dev/null +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Resumidor de Notas de Reuniones", + "position": 13 +} diff --git a/i18n/es/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md b/i18n/es/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md new file mode 100644 index 0000000..4c8822f --- /dev/null +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md @@ -0,0 +1,495 @@ +--- +id: meeting-notes-summarizer +title: "Construye un Resumidor de Notas de Reuniones" +sidebar_label: "Resumidor de Notas de Reuniones" +slug: /projects/meeting-notes-summarizer +description: "Da el salto del entorno de práctica en el navegador a Python real: escribe un script que convierte una transcripción de reunión en bruto en un resumen estructurado — decisiones, elementos de acción y preguntas abiertas — usando un LLM de nivel gratuito y un diseño cuidadoso del prompt." +--- + +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 Resumidor de Notas de Reuniones + + + + + +Todo en el curso hasta ahora se ejecutó en un playground aislado dentro del navegador — para que pudieras empezar a escribir Python desde el primer día sin ninguna configuración. Este proyecto es el paso de graduación: instala Python de verdad en tu propia máquina, y luego úsalo para construir una herramienta que resuelve un problema del mundo real genuinamente molesto — convertir una pared de texto en bruto de transcripción de reunión en un resumen corto y estructurado: qué se decidió, quién es responsable de qué, y qué sigue sin resolver. Esto asume Python 101; nada de Data Analysis es requerido. + +Esto es opcional y no calificado. Consulta [Proyectos del mundo real](/docs/projects) para la lista completa y creciente. + +## 🎯 Qué harás + +1. Instalar `uv`, una herramienta rápida y moderna para gestionar el propio Python y las dependencias de tu proyecto. +2. Obtener una clave de API de LLM de nivel gratuito — cualquiera de seis proveedores funciona. +3. Cargar una transcripción de reunión real (tres muestras realistas se incluyen con este proyecto, así que se ejecuta sin ninguna configuración). +4. Diseñar un prompt que le pide al modelo devolver **JSON estructurado**, no prosa fluida — la habilidad central y transferible de este proyecto. +5. Llamar al modelo, y luego analizar y validar su respuesta JSON — manejando el caso en que vuelve ligeramente malformada, lo cual sucede más a menudo de lo que quisieras. +6. Formatear el resultado estructurado tanto como Markdown legible como un archivo `.json`, y ejecutar todo de principio a fin sobre una transcripción real. + +## Dónde ejecutar esto + +**Localmente con `uv`** es el camino que siguen los pasos de esta lección, y el recomendado — es Python real ejecutándose en tu propia máquina, el mismo movimiento de "graduarte a Python real" que cada otro proyecto de esta sección. La sección de Configuración de abajo explica cómo instalarlo. + +**GitHub Codespaces** es una alternativa sin configuración si prefieres no instalar nada localmente todavía: abre [todo el repositorio del curso en un Codespace gratuito](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python y `uv` ya están instalados, según el `.devcontainer/devcontainer.json` del repositorio) y ejecuta exactamente los mismos comandos `uv` desde una terminal en la pestaña de tu navegador. + +**Google Colab, Kaggle Notebooks o Binder** también funcionan bien, y son opciones genuinamente buenas aquí — este proyecto es un script ligero que hace un puñado de llamadas API, no algo que necesite una GPU o una estructura de proyecto real para ser útil. Una versión de notebook lista para ejecutarse se incluye con este proyecto — haz clic en una insignia abajo para abrirla, sin configuración local requerida — o crea tu propio notebook, ejecuta `!pip install openai python-dotenv` en una celda, pega los scripts de abajo como celdas, y establece tu clave de API con un secreto de notebook (Colab) o una variable de entorno en lugar de un archivo `.env`. + +{/* TODO: update these badge links to point at main once this PR merges */} +[![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/meeting-notes-summarizer/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/meeting-notes-summarizer/notebook.ipynb) +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fmeeting-notes-summarizer%2Fnotebook.ipynb) + +## Configuración + +Todo lo que necesitas antes de escribir cualquier código de resumen — instalar `uv`, crear el proyecto, obtener una clave API gratuita y configurarla como variable de entorno — vive en esta sección, para que solo tengas que hacerlo una vez. + +### 1. Instala `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 +``` + +`uv` también puede obtener y gestionar un intérprete de Python real directamente: + +```bash +uv python install 3.12 +``` + +### 2. Crea el proyecto + +```bash +uv init meeting-notes-summarizer +cd meeting-notes-summarizer +uv add openai python-dotenv +``` + +`uv init` crea un proyecto pequeño (un `pyproject.toml` que rastrea tus dependencias) y `uv add` instala paquetes en un entorno aislado automáticamente — sin configuración manual de entorno virtual. `openai` se usa aquí porque varios proveedores de nivel gratuito, incluyendo el predeterminado sugerido, exponen una API compatible con OpenAI, así que la única biblioteca de cliente funciona en todos ellos, solo apuntada a un `base_url` diferente. `python-dotenv` te permite mantener tu clave de API en un archivo `.env` local en lugar de hacer `export` de ella en cada sesión. + +### 3. Obtén una clave de API de LLM gratuita + +**Elige el proveedor que quieras** — ninguno requiere una tarjeta de crédito al momento de escribir esto, y este curso no favorece a uno sobre otro. + +| Proveedor | Dónde obtener una clave | Por qué podrías elegirlo | +|---|---|---| +| **GitHub Models** *(predeterminado sugerido)* | [github.com/settings/tokens](https://github.com/settings/tokens) — un token de acceso personal con el alcance `models: read` | Sin registro separado — ya tienes una cuenta de GitHub. Límites de nivel gratuito más generosos que los de Gemini. | +| Gemini | [Google AI Studio](https://aistudio.google.com/) | La opción más comúnmente referenciada. | +| Groq | [console.groq.com/keys](https://console.groq.com/keys) | Inferencia rápida, nivel gratuito generoso, sin tarjeta. | +| Mistral | [console.mistral.ai/api-keys](https://console.mistral.ai/api-keys) | Una de las cuotas gratuitas permanentes más generosas. | +| Cerebras | [cloud.cerebras.ai](https://cloud.cerebras.ai/) | Alto volumen de tokens diario, sin tarjeta. | +| OpenRouter | [openrouter.ai/keys](https://openrouter.ai/keys) | Una API, muchos modelos gratuitos — bueno para comparar proveedores. | + +Cualquiera que elijas, el proceso es el mismo: inicia sesión y genera una clave de API en el sitio de ese proveedor. + +### 4. Crea tu archivo `.env` + +**Nunca pegues una clave de API directamente en el código ni la hagas commit a un repositorio.** Crea un archivo `.env` en la carpeta de tu proyecto en su lugar (y asegúrate de que `.env` esté listado en `.gitignore`, justo junto a `.venv`): + +```bash +# .env +GITHUB_TOKEN=your-key-here +``` + +:::tip[Un archivo `.env` supera hacer `export` en cada sesión] +`load_dotenv()` de `python-dotenv` lee `.env` en `os.environ` automáticamente en el momento en que tu script arranca, así que nunca tienes que recordar hacer `export` de una clave en cada nueva ventana de terminal. Consulta el [`examples/meeting-notes-summarizer/.env.example`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/meeting-notes-summarizer) de este curso para ver una plantilla que cubre los seis proveedores. +::: + +Con la configuración lista, todo lo de abajo trata sobre el resumidor en sí. + +## Paso 1: Carga una transcripción de reunión de muestra + +Crea una carpeta `transcripts/` y coloca una transcripción de reunión en texto plano en ella — o copia una de las tres muestras realistas que se incluyen con el ejemplo del repositorio de este proyecto: una reunión diaria de pie, una reunión de planificación de producto y una revisión de incidente (consulta [`examples/meeting-notes-summarizer/sample_transcripts/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/meeting-notes-summarizer/sample_transcripts)). Una transcripción es solo texto plano etiquetado por hablante, nada más sofisticado: + +```text +Maria: Let's start with the API migration. Where are we? +James: About 70% done. I should finish the auth endpoints by Friday. +Maria: Good. Can you also write the migration guide for the team? +James: Yeah, I'll own that too. +Priya: Quick question -- are we still deprecating the v1 endpoints next month? +Maria: Let's hold off on that decision until James finishes the migration. I don't want to commit to a date yet. +``` + +Cargarla es el paso más pequeño posible, deliberadamente: + +```python +# load_transcript.py +"""Loads a plain-text meeting transcript from disk. + +Run with: uv run python load_transcript.py transcripts/standup.txt +""" + +import sys +from pathlib import Path + + +def load_transcript(path: str) -> str: + """Reads a transcript file and returns its raw text.""" + text = Path(path).read_text(encoding="utf-8") + if not text.strip(): + raise ValueError(f"{path} is empty -- nothing to summarize.") + return text + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "transcripts/standup.txt" + transcript = load_transcript(path) + print(f"Loaded {len(transcript)} characters from {path}") + print(transcript[:200] + ("..." if len(transcript) > 200 else "")) +``` + +```bash +uv run python load_transcript.py transcripts/standup.txt +``` + +**✅ Lista de verificación** + + +`uv run python load_transcript.py ` imprime un recuento de caracteres no cero y una vista previa que parece texto de transcripción real. +Ejecutarlo sobre una ruta que no existe levanta un error de Python claro en lugar de no hacer nada silenciosamente. +Ejecutarlo sobre un archivo vacío levanta el `ValueError` que escribiste, no un error confuso en una etapa posterior. + + +**🤔 Pregunta(s) socrática(s)** + +- ¿Por qué verificar una transcripción vacía aquí, en el Paso 1, en lugar de simplemente dejar que un prompt en blanco llegue al LLM en un paso posterior y ver qué pasa? +- Esta función asume que la transcripción completa cabe cómodamente en un solo prompt. ¿Qué transcripción del mundo real rompería esa suposición, y aproximadamente cómo lo sabrías antes de ejecutarla? + +## Paso 2: Diseña un prompt de extracción estructurada + +Esta es la habilidad real que enseña este proyecto: en lugar de pedirle a un modelo un resumen en párrafo de forma libre ("Por favor resume esta reunión"), le pides que devuelva **JSON con una forma específica** — un esquema que tú defines — para que la salida sea algo que tu propio código pueda analizar, almacenar y sobre lo que pueda actuar de forma confiable después. Esta es la misma idea que un contrato de API, solo que aplicado a través de la redacción del prompt en lugar de un sistema de tipos. + +El esquema para este proyecto: tres listas — `decisions`, `action_items` (cada uno con un `task` y un `owner` opcional, cuando la transcripción realmente nombra a uno) y `open_questions`. + +```python +# extract_prompt.py +"""Builds the structured-extraction prompt sent to the LLM. + +Imported by summarize.py (Step 3) -- not meant to be run directly. +""" + +SYSTEM_PROMPT = """You are an assistant that extracts structured information \ +from meeting transcripts. You always respond with a single JSON object and \ +nothing else -- no markdown code fences, no commentary before or after it.""" + +# The exact shape we require back. Spelling this out in the prompt itself, +# field by field, is what makes a small/free-tier model actually follow it -- +# vague instructions like "return the decisions and action items as JSON" +# produce far less consistent shapes across runs. +JSON_SCHEMA_DESCRIPTION = """Respond with a JSON object with EXACTLY these keys: + +{ + "decisions": ["short string describing one decision that was made", ...], + "action_items": [ + {"task": "short string describing the task", "owner": "person's name, or null if not stated"}, + ... + ], + "open_questions": ["short string describing one unresolved question", ...] +} + +Rules: +- Only include a decision if the transcript shows the group actually agreeing on something -- not just discussing an option. +- Only include an action item if someone (or the group) commits to doing it. +- "owner" must be null (not the string "null", not "TBD") when no specific person is named for that task. +- If a category has nothing to report, use an empty list -- never omit the key. +- Do not invent information that isn't in the transcript.""" + + +def build_prompt(transcript: str) -> list[dict]: + """Returns the chat messages list ready to send to the LLM.""" + return [ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": f"{JSON_SCHEMA_DESCRIPTION}\n\nTranscript:\n{transcript}", + }, + ] +``` + +Tres cosas hacen que este diseño de prompt sea deliberado, no accidental: + +1. **El esquema se escribe literalmente**, clave por clave, con una forma de ejemplo — no se describe en prosa. Los modelos son mucho más consistentes igualando un ejemplo que infiriendo un esquema de una descripción. +2. **`owner` explícitamente puede ser `null`**, con una regla explícita sobre cuándo usarlo. Sin esa regla, los modelos tienden a inventar un nombre que suena plausible, o escribir la cadena `"TBD"` — un valor que tu código Python tendría que tratar de forma especial para siempre. +3. **El prompt del sistema declara el formato de salida como una restricción dura** ("nada más -- sin cercas de código markdown, sin comentarios"), porque la forma más común en que esto sale mal (ver Paso 3) es un modelo envolviendo su JSON en una cerca de código ```` ```json ```` por costumbre, incluso cuando se le dice que no lo haga. + +**✅ Lista de verificación** + + +`build_prompt(transcript)` devuelve una lista de dos dicts de mensaje (`system`, `user`), con el texto de la transcripción realmente incrustado en el mensaje de usuario. +Puedes señalar la oración exacta en `JSON_SCHEMA_DESCRIPTION` que le dice al modelo qué hacer cuando no se nombra ningún owner. +Podrías explicar, en una oración, por qué el esquema se escribe como un ejemplo JSON literal en lugar de una descripción en párrafo. + + +**🤔 Pregunta(s) socrática(s)** + +- Si quitaras la regla "Solo incluye una decisión si el grupo realmente acordó algo -- no solo discutir una opción", ¿qué tipo de elementos crees que empezarían a filtrarse en `decisions` en una transcripción llena de debate de ida y vuelta? +- El prompt pide `owner: null` en lugar de omitir el campo por completo. ¿Por qué podría eso ser más fácil de manejar para tu código Python que un esquema donde un campo a veces está presente y a veces simplemente ausente? + +## Paso 3: Llama al LLM y analiza la respuesta JSON + +Ahora envía el prompt y convierte cualquier texto que vuelva en datos reales de Python — un `dict` sobre el que puedes iterar, no una cadena que tengas que inspeccionar con los ojos. Aquí es donde los proyectos de extracción estructurada se rompen más a menudo en la práctica: incluso un prompt bien diseñado ocasionalmente recibe una respuesta envuelta en una cerca de código, con un comentario final, o con una coma extraviada — y un `json.loads()` ingenuo se estrella con los tres. + +```python +# summarize.py (part 1 -- LLM call + parsing) +"""Calls a free-tier LLM to extract a structured summary from a transcript, +then parses and validates the JSON it returns. + +Run with: uv run python summarize.py transcripts/standup.txt +""" + +import json +import os +import re +import sys + +from dotenv import load_dotenv +from openai import OpenAI + +from extract_prompt import build_prompt +from load_transcript import load_transcript + +load_dotenv() + +REQUIRED_KEYS = {"decisions", "action_items", "open_questions"} + + +def call_llm(transcript: str) -> str: + """Sends the structured-extraction prompt and returns the model's raw text reply.""" + client = OpenAI( + api_key=os.environ["GITHUB_TOKEN"], + base_url="https://models.github.ai/inference", + ) + response = client.chat.completions.create( + model="gpt-4o-mini", # confirm this still has a free tier before running + messages=build_prompt(transcript), + temperature=0, # deterministic-as-possible extraction, not creative writing + ) + return response.choices[0].message.content + + +def extract_json(raw_text: str) -> str: + """Strips common wrapping the model adds around JSON despite being told not to. + + Handles the two most frequent offenders: a ```json ... ``` markdown fence, + and leading/trailing prose sentences around an otherwise-valid object. + """ + text = raw_text.strip() + fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL) + if fenced: + return fenced.group(1).strip() + # No fence -- fall back to grabbing everything between the first "{" and + # the last "}", in case the model added a sentence before or after the object. + start, end = text.find("{"), text.rfind("}") + if start != -1 and end != -1 and end > start: + return text[start : end + 1] + return text + + +def parse_summary(raw_text: str) -> dict: + """Parses and validates the model's response, raising a clear error if it + doesn't match the schema after the best-effort cleanup in extract_json().""" + cleaned = extract_json(raw_text) + try: + data = json.loads(cleaned) + except json.JSONDecodeError as error: + raise ValueError( + f"Model response wasn't valid JSON even after cleanup: {error}\n" + f"Raw response was:\n{raw_text}" + ) from error + + if not isinstance(data, dict) or not REQUIRED_KEYS.issubset(data.keys()): + raise ValueError(f"Response is missing required keys {REQUIRED_KEYS}. Got: {data!r}") + + # Normalize: make sure each list field really is a list, even if the + # model returned a single object instead of a one-item list somewhere. + for key in ("decisions", "action_items", "open_questions"): + if not isinstance(data[key], list): + data[key] = [data[key]] + + return data + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "transcripts/standup.txt" + transcript = load_transcript(path) + raw = call_llm(transcript) + summary = parse_summary(raw) + print(json.dumps(summary, indent=2)) +``` + +```bash +uv run python summarize.py transcripts/standup.txt +``` + +:::tip[Nunca confíes a ciegas en la forma de la salida de un LLM] +Trata la respuesta de un modelo de lenguaje igual que tratarías datos de una API no confiable o un CSV subido por un usuario: valídalos antes de usarlos, no los asumas. `extract_json` maneja los problemas comunes de envoltura, y `parse_summary` aún levanta un error claro y específico — con el texto en bruto adjunto — si el resultado realmente no coincide con el esquema, en lugar de dejar que un `KeyError` tres funciones después te haga adivinar qué salió mal. Devolver silenciosamente un resumen vacío en un fallo de análisis sería peor que estrellarse: nunca notarías que la extracción dejó de funcionar silenciosamente. +::: + +**✅ Lista de verificación** + + +`uv run python summarize.py transcripts/standup.txt` imprime JSON válido y legible con las tres claves requeridas. +Puedes explicar qué hace `extract_json` con una respuesta envuelta en ```` ```json ... ``` ````, versus una sin ninguna cerca. +Cambiar temporalmente `REQUIRED_KEYS` para incluir una clave que sabes que no está en el esquema y re-ejecutar produce tu propio `ValueError` claro, no un crash en otro lugar. + + +**🤔 Pregunta(s) socrática(s)** + +- El fallback de `extract_json` — tomar todo entre el primer `{` y el último `}` — se rompería en una transcripción que literalmente contenga llaves en el texto hablado de alguien (ej. citando un fragmento de código). ¿Puedes pensar en un enfoque más robusto, aunque sea más trabajo de implementar? +- ¿Por qué `parse_summary` levanta una excepción con la respuesta en bruto adjunta, en lugar de simplemente devolver `None` cuando el análisis falla? + +## Paso 4: Formatea el resultado como Markdown legible + +El `dict` analizado es exactamente lo que querrías para guardar en una base de datos o alimentar a otro script, pero no es algo que un compañero de equipo quiera leer en un mensaje de Slack. Conviértelo también en un resumen Markdown corto y escaneable — los mismos datos, formateados para un humano en lugar de un programa. + +```python +# format_summary.py +"""Formats a parsed summary dict as readable Markdown. + +Imported by summarize.py (Step 5) -- not meant to be run directly. +""" + + +def format_markdown(summary: dict, source: str) -> str: + lines = [f"# Meeting Summary — {source}", ""] + + lines.append("## Decisions") + if summary["decisions"]: + lines += [f"- {d}" for d in summary["decisions"]] + else: + lines.append("_No decisions recorded._") + lines.append("") + + lines.append("## Action Items") + if summary["action_items"]: + for item in summary["action_items"]: + owner = item.get("owner") or "unassigned" + lines.append(f"- [ ] {item['task']} — **{owner}**") + else: + lines.append("_No action items recorded._") + lines.append("") + + lines.append("## Open Questions") + if summary["open_questions"]: + lines += [f"- {q}" for q in summary["open_questions"]] + else: + lines.append("_No open questions recorded._") + + return "\n".join(lines) +``` + +`item.get("owner") or "unassigned"` está haciendo doble trabajo: maneja tanto un `None` literal (lo que el prompt le pide al modelo usar cuando no se nombra ningún owner) y, defensivamente, una cadena vacía o la palabra `"null"` que algunos modelos más pequeños ocasionalmente producen a pesar de las instrucciones — de cualquier manera, el lector ve "unassigned" en lugar de un espacio en blanco o un `null` literal confuso. + +**✅ Lista de verificación** + + +`format_markdown(summary, "standup.txt")` devuelve una cadena que comienza con un encabezado `# Meeting Summary`. +Un elemento de acción sin owner nombrado se muestra como "unassigned", no un espacio en blanco o la palabra "None". +Pasar un resumen donde cada lista está vacía aún produce Markdown válido y legible (las líneas `_No ... recorded._`), no una sección vacía o rota. + + +**🤔 Pregunta(s) socrática(s)** + +- Los elementos de acción se muestran como `- [ ] task` — sintaxis de casilla de verificación de Markdown con sabor a GitHub. ¿Dónde podría eso ser genuinamente útil versus puramente decorativo, dependiendo de dónde termine este archivo (un issue de GitHub, un mensaje de Slack, un archivo de texto plano)? +- ¿Por qué construir el Markdown a partir del `dict` *ya analizado*, en lugar de pedirle al LLM que genere Markdown directamente en el Paso 3 y omitir este paso? + +## Paso 5: Ejecútalo de principio a fin + +Conecta las piezas: carga una transcripción, llama al modelo, analiza y valida el JSON, y luego escribe tanto un archivo `.md` como un `.json` junto al input. + +```python +# summarize.py (part 2 -- appended to part 1 above) + +from pathlib import Path + +from format_summary import format_markdown + + +def summarize(path: str) -> dict: + """Runs the full pipeline for one transcript and writes both output files.""" + transcript = load_transcript(path) + raw = call_llm(transcript) + summary = parse_summary(raw) + + stem = Path(path).stem + Path(f"{stem}_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + Path(f"{stem}_summary.md").write_text(format_markdown(summary, source=path), encoding="utf-8") + + return summary + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "transcripts/standup.txt" + summary = summarize(path) + print(format_markdown(summary, source=path)) + print(f"\n(also wrote {Path(path).stem}_summary.json and {Path(path).stem}_summary.md)") +``` + +```bash +uv run python summarize.py transcripts/standup.txt +uv run python summarize.py transcripts/product_planning.txt +uv run python summarize.py transcripts/incident_review.txt +``` + +Ejecútalo sobre las tres transcripciones de muestra (o la versión más completa de [`examples/meeting-notes-summarizer/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/meeting-notes-summarizer) del repositorio, que viene con las tres listas) y compara las salidas: una reunión de pie, una reunión de planificación y una revisión de incidente cada una estresa el esquema de manera diferente — la revisión de incidente, por ejemplo, tiende a producir mucho más preguntas abiertas que elementos de acción. + +:::tip[Los límites de tasa son esperados, no un error] +Cada nivel gratuito limita las solicitudes por minuto o por día, y cada llamada a `summarize()` es exactamente una llamada API — así que ejecutar esto sobre varias transcripciones seguidas ocasionalmente puede chocar con un error `429`. Eso es el proveedor diciéndote que vayas más lento, no una señal de que algo esté roto; espera el número de segundos sugerido y vuelve a ejecutar. Consulta el proyecto [AI Agent](/docs/projects/ai-agent#manejar-límites-de-tasa) para ver un patrón de `try`/`except`-con-reintento que puedes copiar directamente si quieres que esto se recupere automáticamente. +::: + +**✅ Lista de verificación** + + +`uv run python summarize.py transcripts/standup.txt` imprime un resumen Markdown legible y reporta escribir dos archivos de salida. +Tanto `standup_summary.json` como `standup_summary.md` existen después, y el archivo JSON es válido (ábrelo, o re-analízalo con `json.load`). +Ejecutarlo sobre una segunda transcripción diferente produce un resumen que realmente refleja el contenido de *esa* transcripción — no una copia de la salida de la primera. + + +**🤔 Pregunta(s) socrática(s)** + +- Si un compañero te pasara una transcripción sin decisiones claras en absoluto — solo lluvia de ideas abierta — ¿qué esperarías que pareciera `decisions`, y la redacción de tu prompt realmente garantiza eso? +- ¿Qué se rompería si ejecutaras esto sobre una transcripción de dos horas y 15,000 palabras en lugar de estas muestras cortas? ¿En qué punto necesitarías una estrategia como el enfoque de fragmentación del proyecto [RAG](/docs/projects/rag-notes) en lugar de enviar todo en un solo prompt? + +## ⚠️ Errores comunes + +- **El modelo envuelve su JSON en una cerca de código markdown de todos modos**, incluso cuando se le dice explícitamente que no — especialmente en modelos más pequeños/de nivel gratuito. `extract_json` en el Paso 3 lo elimina automáticamente; no lo omitas y llames a `json.loads()` directamente sobre la respuesta en bruto. +- **`owner` vuelve como la cadena `"null"`, `"TBD"` o `"N/A"`** en lugar de un `null`/`None` real. `item.get("owner") or "unassigned"` de `format_markdown` atrapa los casos falsy, pero una cadena literal como `"TBD"` se colará tal cual — vale la pena normalizarla explícitamente (ej. `if owner in ("null", "TBD", "N/A", ""): owner = None`) si lo ves ocurrir a menudo con tu proveedor elegido. +- **Olvidar `temperature=0`.** Las tareas de extracción quieren que la misma transcripción produzca un resumen consistente y repetible — no variación creativa entre ejecuciones. Dejar el predeterminado (a menudo `~1.0`) hace que los resultados sean notablemente menos estables de ejecución en ejecución, lo que dificulta depurar tu prompt porque no puedes saber si un cambio en la salida vino de tu edición del prompt o solo de la aleatoriedad. +- **Límites de tasa en el nivel gratuito del LLM.** Cada llamada a `summarize()` cuesta una solicitud contra la cuota de tu proveedor; ejecutarlo sobre muchas transcripciones rápidamente puede disparar un 429. Consulta el consejo de arriba. + +## Lo que acabas de construir + +Un pipeline de extracción estructurada pequeño y completo: carga texto en bruto, diseña un prompt que fija un esquema de salida exacto, llama a un LLM de nivel gratuito, analiza y valida defensivamente lo que vuelve, y renderiza el resultado tanto para máquinas (JSON) como para humanos (Markdown). Esto no es una simplificación de juguete — exactamente la misma forma (prompt restringido por esquema → analizar → validar → degradarse con gracia) es como los sistemas de producción extraen datos estructurados de currículos, facturas, tickets de soporte y contratos. Cambia el esquema y el prompt, y este pipeline todavía funciona. + +## A dónde ir desde aquí + +- Extiende el esquema con un campo `sentiment` o `meeting_type`, o una `priority` en cada elemento de acción — el patrón (describe el campo en el prompt, valídalo después del análisis) es idéntico al que ya construiste. +- Prueba alimentar al modelo una transcripción en un formato completamente diferente (una exportación de chat, un archivo de subtítulos cerrados `.vtt` en bruto) y observa cuánta limpieza necesita `load_transcript` antes de que los resultados sigan siendo buenos. +- Investiga una biblioteca de validación de esquemas como `pydantic` para una versión mucho más estricta de `parse_summary` — en lugar de verificar las claves a mano, define un modelo `Summary` una vez y deja que valide (e incluso fuerce) los tipos por ti, levantando un error estructurado sobre cualquier cosa que no encaje. +- Combina esto con el proyecto [AI Agent](/docs/projects/ai-agent): dale al agente una herramienta que llame a `summarize()` sobre un archivo de transcripción, para que pueda decidir *cuándo* resumir como parte de una tarea más grande en lugar de que siempre ejecutes el script a mano. + +## 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 17401df..6e46032 100644 --- a/i18n/fr/code.json +++ b/i18n/fr/code.json @@ -893,5 +893,13 @@ "homepage.projects.mcpSqliteServer.summary": { "message": "Construisez un serveur MCP qui expose une base de données SQLite locale, puis observez un client LLM écrire et exécuter son propre SQL pour répondre à des questions en langage naturel à son sujet.", "description": "Homepage project card summary" + }, + "homepage.projects.meetingNotesSummarizer.title": { + "message": "Construire un Résumeur de Notes de Réunion", + "description": "Homepage project card title" + }, + "homepage.projects.meetingNotesSummarizer.summary": { + "message": "Transforme une transcription de réunion brute en décisions structurées, éléments d'action et questions ouvertes, en utilisant un LLM de niveau gratuit et un prompt d'extraction JSON soigneusement conçu.", + "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 3adcd0b..76e9d39 100644 --- a/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -137,5 +137,11 @@ Ils sont optionnels et non notés. Parcourez-les à tout moment — l'introducti summary: 'Construisez un serveur MCP qui expose une base de données SQLite locale, puis observez un client LLM écrire et exécuter son propre SQL pour répondre à des questions en langage naturel à son sujet.', }, + { + id: 'meeting-notes-summarizer', + title: 'Construire un Résumeur de Notes de Réunion', + summary: + "Transforme une transcription de réunion brute en décisions structurées, éléments d'action et questions ouvertes, en utilisant un LLM de niveau gratuit et un prompt d'extraction JSON soigneusement conçu.", + }, ])} /> diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json b/i18n/fr/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json new file mode 100644 index 0000000..3eef125 --- /dev/null +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Résumeur de Notes de Réunion", + "position": 13 +} diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md b/i18n/fr/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md new file mode 100644 index 0000000..2b7add1 --- /dev/null +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/meeting-notes-summarizer/index.md @@ -0,0 +1,495 @@ +--- +id: meeting-notes-summarizer +title: "Construire un Résumeur de Notes de Réunion" +sidebar_label: "Résumeur de Notes de Réunion" +slug: /projects/meeting-notes-summarizer +description: "Passe du bac à sable dans le navigateur à du vrai Python : écris un script qui transforme une transcription brute de réunion en résumé structuré — décisions, éléments d'action et questions ouvertes — en utilisant un LLM gratuit et une conception soignée du prompt." +--- + +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 Résumeur de Notes de Réunion + + + + + +Tout dans le cours jusqu'ici tournait dans un bac à sable isolé, dans le navigateur — pour que tu puisses commencer à écrire du Python dès le premier jour sans aucune configuration. Ce projet est l'étape de remise de diplôme : installe Python pour de vrai sur ta propre machine, puis utilise-le pour construire un outil qui résout un problème du monde réel authentiquement agaçant — transformer un mur de texte brut de transcription de réunion en un résumé court et structuré : ce qui a été décidé, qui doit quoi, et ce qui reste sans solution. Cela suppose du Python 101 ; rien de Data Analysis n'est requis. + +Ceci 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`, un outil rapide et moderne pour gérer Python lui-même et les dépendances de ton projet. +2. Obtenir une clé API LLM de palier gratuit — l'un des six fournisseurs fonctionne. +3. Charger une transcription de réunion réelle (trois échantillons réalistes sont fournis avec ce projet, donc ça tourne sans aucune configuration). +4. Concevoir un prompt qui demande au modèle de renvoyer du **JSON structuré**, pas de la prose fluide — la compétence centrale et transférable de ce projet. +5. Appeler le modèle, puis analyser et valider sa réponse JSON — en gérant le cas où elle revient légèrement malformée, ce qui arrive plus souvent que tu ne le souhaiterais. +6. Formater le résultat structuré à la fois en Markdown lisible et en fichier `.json`, et exécuter le tout de bout en bout sur une transcription réelle. + +## Où exécuter ceci + +**En local avec `uv`** est le chemin que suivent les étapes de cette leçon, et celui recommandé — c'est du vrai Python qui tourne sur ta propre machine, la même démarche de « passage au vrai Python » que chaque autre projet de cette section. La section Configuration ci-dessous explique comment l'installer. + +**GitHub Codespaces** est une alternative sans configuration si tu préfères ne rien installer localement pour l'instant : ouvre [tout le dépôt du cours dans un Codespace gratuit](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python et `uv` sont déjà installés, selon le `.devcontainer/devcontainer.json` du dépôt) et exécute exactement les mêmes commandes `uv` depuis un terminal dans l'onglet de ton navigateur. + +**Google Colab, Kaggle Notebooks, ou Binder** fonctionnent bien aussi, et sont de bonnes options ici — ce projet est un script léger qui effectue une poignée d'appels API, pas quelque chose qui a besoin d'un GPU ou d'une vraie structure de projet pour être utile. Une version notebook prête à l'emploi est fournie avec ce projet — clique sur un badge ci-dessous pour l'ouvrir, aucune configuration locale requise — ou crée ton propre notebook, exécute `!pip install openai python-dotenv` dans une cellule, colle les scripts ci-dessous en tant que cellules, et définis ta clé API avec un secret de notebook (Colab) ou une variable d'environnement au lieu d'un fichier `.env`. + +{/* TODO: update these badge links to point at main once this PR merges */} +[![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/meeting-notes-summarizer/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/meeting-notes-summarizer/notebook.ipynb) +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fmeeting-notes-summarizer%2Fnotebook.ipynb) + +## Configuration + +Tout ce dont tu as besoin avant d'écrire le moindre code de résumé — installer `uv`, créer le projet, obtenir une clé API gratuite et la définir comme variable d'environnement — vit dans cette seule section, pour que tu n'aies à le faire qu'une seule fois. + +### 1. Installe `uv` + +`uv` est un seul outil qui remplace la chaîne habituelle « installe Python, puis installe pip, puis installe un outil d'environnement virtuel, puis installe les paquets » — il peut installer et gérer les versions de Python lui-même, en plus des dépendances de ton projet. + +**macOS / Linux** (terminal) : + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +**Windows** (PowerShell) : + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +Ferme et rouvre ton terminal, puis confirme que c'est installé : + +```bash +uv --version +``` + +`uv` peut aussi récupérer et gérer un véritable interpréteur Python directement : + +```bash +uv python install 3.12 +``` + +### 2. Crée le projet + +```bash +uv init meeting-notes-summarizer +cd meeting-notes-summarizer +uv add openai python-dotenv +``` + +`uv init` crée un petit projet (un `pyproject.toml` qui suit tes dépendances) et `uv add` installe les paquets dans un environnement isolé automatiquement — aucune configuration manuelle d'environnement virtuel. `openai` est utilisé ici parce que plusieurs fournisseurs de palier gratuit, dont le défaut suggéré, exposent une API compatible OpenAI, donc la seule bibliothèque client fonctionne sur tous, juste pointée vers un `base_url` différent. `python-dotenv` te permet de garder ta clé API dans un fichier `.env` local au lieu de faire `export` à chaque session. + +### 3. Obtiens une clé API LLM gratuite + +**Choisis le fournisseur que tu veux** — aucun n'exige de carte de crédit au moment de la rédaction, et ce cours n'en privilégie aucun. + +| 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 champ d'application `models: read` | Aucune inscription séparée — tu as déjà un compte GitHub. Limites de palier gratuit plus généreuses que celles de Gemini. | +| Gemini | [Google AI Studio](https://aistudio.google.com/) | L'option la plus couramment référencée. | +| Groq | [console.groq.com/keys](https://console.groq.com/keys) | Inférence rapide, palier 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 — idéal pour comparer les fournisseurs. | + +Quel que soit ton choix, le processus est le même : connecte-toi et génère une clé API sur le site de ce fournisseur. + +### 4. Crée ton fichier `.env` + +**Ne colle jamais une clé API directement dans le code et ne la commets jamais dans un dépôt.** Crée plutôt un fichier `.env` dans le dossier de ton projet (et assure-toi que `.env` est listé dans `.gitignore`, juste à côté de `.venv`) : + +```bash +# .env +GITHUB_TOKEN=your-key-here +``` + +:::tip[Un fichier `.env` vaut mieux que faire `export` à chaque session] +`load_dotenv()` de `python-dotenv` lit `.env` dans `os.environ` automatiquement dès que ton script démarre, donc tu n'as jamais à penser à faire `export` d'une clé dans chaque nouvelle fenêtre de terminal. Voir le [`examples/meeting-notes-summarizer/.env.example`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/meeting-notes-summarizer) de ce cours pour un modèle couvrant les six fournisseurs. +::: + +La configuration terminée, tout ce qui suit concerne le résumeur lui-même. + +## Étape 1 : Charge une transcription de réunion d'échantillon + +Crée un dossier `transcripts/` et déposes-y une transcription de réunion en texte brut — ou copie l'un des trois échantillons réalistes fournis avec l'exemple du dépôt de ce projet : un daily standup, une réunion de planification produit et une revue d'incident (voir [`examples/meeting-notes-summarizer/sample_transcripts/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/meeting-notes-summarizer/sample_transcripts)). Une transcription est juste du texte brut étiqueté par intervenant, rien de plus sophistiqué : + +```text +Maria: Let's start with the API migration. Where are we? +James: About 70% done. I should finish the auth endpoints by Friday. +Maria: Good. Can you also write the migration guide for the team? +James: Yeah, I'll own that too. +Priya: Quick question -- are we still deprecating the v1 endpoints next month? +Maria: Let's hold off on that decision until James finishes the migration. I don't want to commit to a date yet. +``` + +La charger est l'étape la plus petite possible, délibérément : + +```python +# load_transcript.py +"""Loads a plain-text meeting transcript from disk. + +Run with: uv run python load_transcript.py transcripts/standup.txt +""" + +import sys +from pathlib import Path + + +def load_transcript(path: str) -> str: + """Reads a transcript file and returns its raw text.""" + text = Path(path).read_text(encoding="utf-8") + if not text.strip(): + raise ValueError(f"{path} is empty -- nothing to summarize.") + return text + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "transcripts/standup.txt" + transcript = load_transcript(path) + print(f"Loaded {len(transcript)} characters from {path}") + print(transcript[:200] + ("..." if len(transcript) > 200 else "")) +``` + +```bash +uv run python load_transcript.py transcripts/standup.txt +``` + +**✅ Liste de vérification** + + +`uv run python load_transcript.py ` affiche un nombre de caractères non nul et un aperçu qui ressemble à du vrai texte de transcription. +L'exécuter sur un chemin qui n'existe pas lève une erreur Python claire plutôt que de ne rien faire en silence. +L'exécuter sur un fichier vide lève le `ValueError` que tu as écrit, pas une erreur déroutante plus tard. + + +**🤔 Question(s) socratique(s)** + +- Pourquoi vérifier ici, à l'Étape 1, qu'une transcription n'est pas vide, plutôt que de laisser un prompt vide atteindre le LLM à une étape ultérieure et voir ce qui se passe ? +- Cette fonction suppose que la transcription entière tient confortablement dans un seul prompt. Quelle transcription du monde réel briserait cette supposition, et comment le saurais-tu à peu près avant de l'exécuter ? + +## Étape 2 : Conçois un prompt d'extraction structurée + +C'est la vraie compétence que ce projet enseigne : au lieu de demander à un modèle un résumé en paragraphe de forme libre (« Veuillez résumer cette réunion »), tu lui demandes de renvoyer du **JSON avec une forme spécifique** — un schéma que tu définis — pour que la sortie soit quelque chose que ton propre code peut analyser, stocker et sur lequel il peut agir de manière fiable par la suite. C'est la même idée qu'un contrat d'API, simplement appliquée par le biais du libellé du prompt plutôt que d'un système de types. + +Le schéma de ce projet : trois listes — `decisions`, `action_items` (chacun avec une `task` et un `owner` optionnel, lorsque la transcription en nomme réellement un) et `open_questions`. + +```python +# extract_prompt.py +"""Builds the structured-extraction prompt sent to the LLM. + +Imported by summarize.py (Step 3) -- not meant to be run directly. +""" + +SYSTEM_PROMPT = """You are an assistant that extracts structured information \ +from meeting transcripts. You always respond with a single JSON object and \ +nothing else -- no markdown code fences, no commentary before or after it.""" + +# The exact shape we require back. Spelling this out in the prompt itself, +# field by field, is what makes a small/free-tier model actually follow it -- +# vague instructions like "return the decisions and action items as JSON" +# produce far less consistent shapes across runs. +JSON_SCHEMA_DESCRIPTION = """Respond with a JSON object with EXACTLY these keys: + +{ + "decisions": ["short string describing one decision that was made", ...], + "action_items": [ + {"task": "short string describing the task", "owner": "person's name, or null if not stated"}, + ... + ], + "open_questions": ["short string describing one unresolved question", ...] +} + +Rules: +- Only include a decision if the transcript shows the group actually agreeing on something -- not just discussing an option. +- Only include an action item if someone (or the group) commits to doing it. +- "owner" must be null (not the string "null", not "TBD") when no specific person is named for that task. +- If a category has nothing to report, use an empty list -- never omit the key. +- Do not invent information that isn't in the transcript.""" + + +def build_prompt(transcript: str) -> list[dict]: + """Returns the chat messages list ready to send to the LLM.""" + return [ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": f"{JSON_SCHEMA_DESCRIPTION}\n\nTranscript:\n{transcript}", + }, + ] +``` + +Trois choses rendent cette conception de prompt délibérée, pas accidentelle : + +1. **Le schéma est écrit littéralement**, clé par clé, avec une forme d'exemple — pas décrit en prose. Les modèles sont bien plus cohérents pour correspondre à un exemple que pour déduire un schéma d'une description. +2. **`owner` est explicitement autorisé à être `null`**, avec une règle explicite pour savoir quand l'utiliser. Sans cette règle, les modèles ont tendance à inventer un nom plausible, ou à écrire la chaîne `"TBD"` — une valeur que ton code Python devrait ensuite gérer de manière spéciale pour toujours. +3. **Le prompt système énonce le format de sortie comme une contrainte dure** (« rien d'autre — pas de délimiteurs de code markdown, pas de commentaires »), parce que la façon la plus courante que cela déraille (voir l'Étape 3) est qu'un modèle enveloppe son JSON dans un délimiteur de code ```` ```json ```` par habitude, même quand on lui dit de ne pas le faire. + +**✅ Liste de vérification** + + +`build_prompt(transcript)` renvoie une liste de deux dicts de message (`system`, `user`), avec le texte de la transcription réellement intégré dans le message utilisateur. +Tu peux montrer la phrase exacte dans `JSON_SCHEMA_DESCRIPTION` qui dit au modèle quoi faire quand aucun owner n'est nommé. +Tu pourrais expliquer, en une phrase, pourquoi le schéma est écrit comme un exemple JSON littéral au lieu d'une description en paragraphe. + + +**🤔 Question(s) socratique(s)** + +- Si tu supprimais la règle « N'inclus une décision que si le groupe s'est réellement mis d'accord -- pas juste discuté d'une option », quel genre d'éléments penses-tu commencerait à s'infiltrer dans `decisions` sur une transcription pleine de débats aller-retour ? +- Le prompt demande `owner: null` plutôt que d'omettre entièrement le champ. Pourquoi cela pourrait-il être plus facile à gérer pour ton code Python qu'un schéma où un champ est parfois présent et parfois simplement absent ? + +## Étape 3 : Appelle le LLM et analyse la réponse JSON + +Envoie maintenant le prompt et transforme tout texte qui revient en vraies données Python — un `dict` sur lequel tu peux itérer, pas une chaîne que tu dois inspecter à l'œil. C'est là que les projets d'extraction structurée cassent le plus souvent en pratique : même un prompt bien conçu reçoit occasionnellement une réponse enveloppée dans un délimiteur de code, avec un commentaire de fin, ou avec une virgule égarée — et un `json.loads()` naïf plante sur les trois. + +```python +# summarize.py (part 1 -- LLM call + parsing) +"""Calls a free-tier LLM to extract a structured summary from a transcript, +then parses and validates the JSON it returns. + +Run with: uv run python summarize.py transcripts/standup.txt +""" + +import json +import os +import re +import sys + +from dotenv import load_dotenv +from openai import OpenAI + +from extract_prompt import build_prompt +from load_transcript import load_transcript + +load_dotenv() + +REQUIRED_KEYS = {"decisions", "action_items", "open_questions"} + + +def call_llm(transcript: str) -> str: + """Sends the structured-extraction prompt and returns the model's raw text reply.""" + client = OpenAI( + api_key=os.environ["GITHUB_TOKEN"], + base_url="https://models.github.ai/inference", + ) + response = client.chat.completions.create( + model="gpt-4o-mini", # confirm this still has a free tier before running + messages=build_prompt(transcript), + temperature=0, # deterministic-as-possible extraction, not creative writing + ) + return response.choices[0].message.content + + +def extract_json(raw_text: str) -> str: + """Strips common wrapping the model adds around JSON despite being told not to. + + Handles the two most frequent offenders: a ```json ... ``` markdown fence, + and leading/trailing prose sentences around an otherwise-valid object. + """ + text = raw_text.strip() + fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL) + if fenced: + return fenced.group(1).strip() + # No fence -- fall back to grabbing everything between the first "{" and + # the last "}", in case the model added a sentence before or after the object. + start, end = text.find("{"), text.rfind("}") + if start != -1 and end != -1 and end > start: + return text[start : end + 1] + return text + + +def parse_summary(raw_text: str) -> dict: + """Parses and validates the model's response, raising a clear error if it + doesn't match the schema after the best-effort cleanup in extract_json().""" + cleaned = extract_json(raw_text) + try: + data = json.loads(cleaned) + except json.JSONDecodeError as error: + raise ValueError( + f"Model response wasn't valid JSON even after cleanup: {error}\n" + f"Raw response was:\n{raw_text}" + ) from error + + if not isinstance(data, dict) or not REQUIRED_KEYS.issubset(data.keys()): + raise ValueError(f"Response is missing required keys {REQUIRED_KEYS}. Got: {data!r}") + + # Normalize: make sure each list field really is a list, even if the + # model returned a single object instead of a one-item list somewhere. + for key in ("decisions", "action_items", "open_questions"): + if not isinstance(data[key], list): + data[key] = [data[key]] + + return data + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "transcripts/standup.txt" + transcript = load_transcript(path) + raw = call_llm(transcript) + summary = parse_summary(raw) + print(json.dumps(summary, indent=2)) +``` + +```bash +uv run python summarize.py transcripts/standup.txt +``` + +:::tip[Ne fais jamais aveuglément confiance à la forme de la sortie d'un LLM] +Traite la réponse d'un modèle de langage comme tu traiterais des données provenant d'une API non fiable ou d'un CSV téléchargé par un utilisateur : valide-les avant de les utiliser, ne les présume pas. `extract_json` gère les problèmes d'enveloppement courants, et `parse_summary` lève toujours une erreur claire et spécifique — avec le texte brut joint — si le résultat ne correspond vraiment pas au schéma, plutôt que de laisser un `KeyError` trois fonctions plus tard te laisser deviner ce qui a mal tourné. Renvoyer silencieusement un résumé vide en cas d'échec d'analyse serait pire que de planter : tu ne remarquerais jamais que l'extraction a silencieusement cessé de fonctionner. +::: + +**✅ Liste de vérification** + + +`uv run python summarize.py transcripts/standup.txt` affiche du JSON valide et lisible avec les trois clés requises. +Tu peux expliquer ce que fait `extract_json` avec une réponse enveloppée dans ```` ```json ... ``` ````, par rapport à une sans aucun délimiteur. +Changer temporairement `REQUIRED_KEYS` pour inclure une clé dont tu sais qu'elle n'est pas dans le schéma et relancer produit ton propre `ValueError` clair, pas un plantage ailleurs. + + +**🤔 Question(s) socratique(s)** + +- Le repli de `extract_json` — saisir tout ce qui se trouve entre le premier `{` et le dernier `}` — casserait sur une transcription qui contient littéralement des accolades dans le texte prononcé par quelqu'un (par exemple en citant un extrait de code). Peux-tu imaginer une approche plus robuste, même si c'est plus de travail à implémenter ? +- Pourquoi `parse_summary` lève-t-il une exception avec la réponse brute jointe, au lieu de simplement renvoyer `None` quand l'analyse échoue ? + +## Étape 4 : Formate le résultat en Markdown lisible + +Le `dict` analysé est exactement ce que tu voudrais pour enregistrer dans une base de données ou alimenter un autre script, mais ce n'est pas quelque chose qu'un collègue veut lire dans un message Slack. Convertis-le aussi en un court résumé Markdown facile à parcourir — les mêmes données, formatées pour un humain plutôt que pour un programme. + +```python +# format_summary.py +"""Formats a parsed summary dict as readable Markdown. + +Imported by summarize.py (Step 5) -- not meant to be run directly. +""" + + +def format_markdown(summary: dict, source: str) -> str: + lines = [f"# Meeting Summary — {source}", ""] + + lines.append("## Decisions") + if summary["decisions"]: + lines += [f"- {d}" for d in summary["decisions"]] + else: + lines.append("_No decisions recorded._") + lines.append("") + + lines.append("## Action Items") + if summary["action_items"]: + for item in summary["action_items"]: + owner = item.get("owner") or "unassigned" + lines.append(f"- [ ] {item['task']} — **{owner}**") + else: + lines.append("_No action items recorded._") + lines.append("") + + lines.append("## Open Questions") + if summary["open_questions"]: + lines += [f"- {q}" for q in summary["open_questions"]] + else: + lines.append("_No open questions recorded._") + + return "\n".join(lines) +``` + +`item.get("owner") or "unassigned"` fait double emploi : il gère à la fois un `None` littéral (ce que le prompt demande au modèle d'utiliser quand aucun owner n'est nommé) et, défensivement, une chaîne vide ou le mot `"null"` que certains petits modèles produisent occasionnellement malgré les instructions — dans les deux cas, le lecteur voit « unassigned » au lieu d'un blanc ou d'un `null` littéral déroutant. + +**✅ Liste de vérification** + + +`format_markdown(summary, "standup.txt")` renvoie une chaîne commençant par un en-tête `# Meeting Summary`. +Un élément d'action sans owner nommé s'affiche comme « unassigned », pas un blanc ou le mot « None ». +Passer un résumé où chaque liste est vide produit toujours un Markdown valide et lisible (les lignes `_No ... recorded._`), pas une section vide ou cassée. + + +**🤔 Question(s) socratique(s)** + +- Les éléments d'action s'affichent comme `- [ ] task` — la syntaxe de case à cocher du Markdown au goût GitHub. Où cela pourrait-il être réellement utile plutôt que purement décoratif, selon l'endroit où ce fichier atterrit (un issue GitHub, un message Slack, un fichier texte brut) ? +- Pourquoi construire le Markdown à partir du `dict` *déjà analysé*, plutôt que de demander au LLM de générer directement du Markdown à l'Étape 3 et de sauter cette étape ? + +## Étape 5 : Exécute-le de bout en bout + +Raccorde les pièces : charge une transcription, appelle le modèle, analyse et valide le JSON, puis écris à la fois un fichier `.md` et un fichier `.json` à côté de l'entrée. + +```python +# summarize.py (part 2 -- appended to part 1 above) + +from pathlib import Path + +from format_summary import format_markdown + + +def summarize(path: str) -> dict: + """Runs the full pipeline for one transcript and writes both output files.""" + transcript = load_transcript(path) + raw = call_llm(transcript) + summary = parse_summary(raw) + + stem = Path(path).stem + Path(f"{stem}_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + Path(f"{stem}_summary.md").write_text(format_markdown(summary, source=path), encoding="utf-8") + + return summary + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "transcripts/standup.txt" + summary = summarize(path) + print(format_markdown(summary, source=path)) + print(f"\n(also wrote {Path(path).stem}_summary.json and {Path(path).stem}_summary.md)") +``` + +```bash +uv run python summarize.py transcripts/standup.txt +uv run python summarize.py transcripts/product_planning.txt +uv run python summarize.py transcripts/incident_review.txt +``` + +Exécute-le sur les trois transcriptions d'échantillon (ou la version plus complète de [`examples/meeting-notes-summarizer/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/meeting-notes-summarizer) du dépôt, qui les fournit toutes les trois prêtes à l'emploi) et compare les sorties : un standup, une réunion de planification et une revue d'incident sollicitent chacune le schéma différemment — la revue d'incident, par exemple, a tendance à produire beaucoup plus de questions ouvertes que d'éléments d'action. + +:::tip[Les limites de débit sont attendues, pas un bug] +Chaque palier gratuit plafonne les requêtes par minute ou par jour, et chaque appel à `summarize()` est exactement un appel API — donc l'exécuter sur plusieurs transcriptions à la suite peut occasionnellement rencontrer une erreur `429`. C'est le fournisseur qui te dit de ralentir, pas un signe que quelque chose est cassé ; attends le nombre de secondes suggéré et relance. Voir le projet [AI Agent](/docs/projects/ai-agent#gérer-les-limites-de-débit) pour un motif `try`/`except`-avec-réessai que tu peux copier directement si tu veux que cela récupère automatiquement. +::: + +**✅ Liste de vérification** + + +`uv run python summarize.py transcripts/standup.txt` affiche un résumé Markdown lisible et signale l'écriture de deux fichiers de sortie. +`standup_summary.json` et `standup_summary.md` existent tous les deux ensuite, et le fichier JSON est valide (ouvre-le, ou réanalyse-le avec `json.load`). +L'exécuter sur une deuxième transcription différente produit un résumé qui reflète réellement le contenu de *cette* transcription — pas une copie de la sortie de la première. + + +**🤔 Question(s) socratique(s)** + +- Si un collègue te remettait une transcription sans décision claire du tout — juste du brainstorming ouvert — à quoi t'attendrais-tu que `decisions` ressemble, et le libellé de ton prompt garantit-il réellement cela ? +- Qu'est-ce qui casserait si tu exécutais cela sur une transcription de deux heures et 15 000 mots au lieu de ces courts échantillons ? À quel moment aurais-tu besoin d'une stratégie comme l'approche de découpage du projet [RAG](/docs/projects/rag-notes) au lieu d'envoyer le tout dans un seul prompt ? + +## ⚠️ Pièges courants + +- **Le modèle enveloppe quand même son JSON dans un délimiteur de code markdown**, même quand on lui dit explicitement de ne pas le faire — surtout sur les modèles plus petits/de palier gratuit. `extract_json` à l'Étape 3 le retire automatiquement ; ne le saute pas et n'appelle pas `json.loads()` directement sur la réponse brute. +- **`owner` revient comme la chaîne `"null"`, `"TBD"` ou `"N/A"`** au lieu d'un vrai `null`/`None`. `item.get("owner") or "unassigned"` de `format_markdown` attrape les cas falsy, mais une chaîne littérale comme `"TBD"` passera telle quelle — ça vaut la peine de normaliser explicitement (par ex. `if owner in ("null", "TBD", "N/A", ""): owner = None`) si tu vois cela arriver souvent avec ton fournisseur choisi. +- **Oublier `temperature=0`.** Les tâches d'extraction veulent que la même transcription produise un résumé cohérent et reproductible — pas une variation créative entre les exécutions. Laisser le défaut (souvent `~1.0`) rend les résultats nettement moins stables d'une exécution à l'autre, ce qui rend le débogage de ton prompt plus difficile car tu ne peux pas savoir si un changement de sortie vient de ta modification du prompt ou juste de l'aléatoire. +- **Limites de débit sur le palier LLM gratuit.** Chaque appel à `summarize()` coûte une requête contre le quota de ton fournisseur ; l'exécuter sur de nombreuses transcriptions rapidement peut déclencher un 429. Voir le conseil ci-dessus. + +## Ce que tu viens de construire + +Un pipeline d'extraction structurée petit et complet : charger du texte brut, concevoir un prompt qui fige un schéma de sortie exact, appeler un LLM de palier gratuit, analyser et valider défensivement ce qui revient, et rendre le résultat à la fois pour les machines (JSON) et les humains (Markdown). Ce n'est pas une simplification jouet — exactement la même forme (prompt contraint par schéma → analyser → valider → se rabattre avec grâce) est la façon dont les systèmes de production extraient des données structurées de CV, factures, tickets de support et contrats. Échange le schéma et le prompt, et ce pipeline fonctionne toujours. + +## Où aller à partir d'ici + +- Étends le schéma avec un champ `sentiment` ou `meeting_type`, ou une `priority` sur chaque élément d'action — le motif (décrire le champ dans le prompt, le valider après l'analyse) est identique à ce que tu as déjà construit. +- Essaie de donner au modèle une transcription dans un format complètement différent (un export de chat, un fichier de sous-titres `.vtt` brut) et vois combien de nettoyage `load_transcript` a besoin avant que les résultats restent bons. +- Intéresse-toi à une bibliothèque de validation de schéma comme `pydantic` pour une version beaucoup plus stricte de `parse_summary` — au lieu de vérifier les clés à la main, définis un modèle `Summary` une fois et laisse-le valider (et même contraindre) les types pour toi, en levant une erreur structurée sur tout ce qui ne correspond pas. +- Combine cela avec le projet [AI Agent](/docs/projects/ai-agent) : donne à l'agent un outil qui appelle `summarize()` sur un fichier de transcription, pour qu'il puisse décider *quand* résumer dans le cadre d'une tâche plus grande au lieu que tu exécutes toujours le script à la main. + +## 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. 🎓 + +