Commit Graph

68 Commits

Author SHA1 Message Date
07fab4c094 write down what the last two days changed about content and answer quality
CLAUDE.md تنها جایی است که هر جلسه‌ی تازه اول می‌خواندش، و چیزهایی که این دوره
یاد گرفتیم جایی ثبت نشده بود. چهار بخش اضافه شد و «وضعیتِ فعلی» به بالا آمد:

• محتوا حالا در content/seed/ کامیت می‌شود و deploy خودش بالا می‌آوردش — بدونِ
  Qdrant، بدونِ اعتبارِ AI، بدونِ SSH. و اینکه سوال با مسیرِ عنوان به درس وصل
  می‌شود نه با id، چون idها بینِ دو دیتابیس یکی نیستند.
• لایه‌ی راستی‌آزمایی: کتاب از روی تصویر خوانده می‌شود، پس خطا همان‌جا وارد
  می‌شود. برچسب سه‌حالته است و سوال فقط از agreesها ساخته می‌شود.
• دو درسی که گران تمام شدند: نتیجه‌ی دوحالته نرخ را سه برابر نشان می‌دهد، و
  «۳/۱۰۴» در این کتاب‌ها عددِ اعشاری است نه کسر.
• شکافِ شکل‌ها (۴۲٪ محتوا) و اینکه اصلاحش در پرامپت هست ولی اجرا نشده.
• تفاوتِ ۴۲۹ سقفِ حساب با ۴۰۳ اتمامِ اعتبار — و اینکه متنِ دومی فاصله دارد نه
  زیرخط، که یک بار ۷۷۰ فراخوانیِ بی‌فایده به بار آورد.

«وضعیت تا ۲۰۲۶-۰۹-۱۷» هم اضافه شد: بک‌اند آماده و تست‌شده است، و دو مانعِ
باقی‌مانده دستِ ما نیست — سرور روی کدِ قدیمی، و فرانتی که به هیچ APIی وصل نیست.
هر دو قبل از اینکه کسی دنبالِ باگ بگردد باید چک شوند.

هیچ فایلِ تازه‌ای اضافه نشد؛ همان فایلِ موجود به‌روز شد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:59:19 +03:30
16d3d9f149 add the last 81 quiz questions
دستهٔ c5. ۱٬۳۶۰ → ۱٬۴۴۱.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:36:34 +03:30
e89de084e7 add 161 more quiz questions from verified textbook solutions
دسته‌ی c2 و باقی‌ماندهٔ دیشب. ۱٬۱۹۹ → ۱٬۳۶۰.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:25:10 +03:30
b26aca0f63 label every textbook solution, and build quizzes at the level the app plays
سه کارِ به‌هم‌پیوسته که از یک کشف شروع شدند: راه‌حل‌هایی که vision از کتاب
درآورده گاهی غلط‌اند، و این خطا در بازیابی دیده نمی‌شود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان می‌دهد.

۱) بازبینیِ راه‌حل‌ها (rag_verify_solutions)

هر chunk دوباره حل می‌شود و با کتاب مقایسه. نتیجه سه‌حالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونه‌ها نشان داد ۳۵٪ از
آن‌ها مسئله‌هایی بودند که به شکلِ کتاب ارجاع می‌دادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمی‌دانم» ناچار به «غلط است» ترجمه می‌شد.

با سه حالت و پرامپتی که این دو تله را می‌شناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیش‌فیلتر قبل از هر فراخوانی کنارشان گذاشت.

`solution` هرگز بازنویسی نمی‌شود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم می‌نشیند، ولی با نامی که `retrieval._payload_text` نمی‌خواند: یک
نویسه هم به متنی که به مدل می‌رسد اضافه نشد.

نمونه‌ی خطاهای واقعیِ کتاب که پیدا شد:
    BMI = ۱۰۰/(۲٫۷۴)²          کتاب: ۳۳٫۲      درست: ۱۳٫۳
    ۱۶ آبی + ۲۴ صورتی          کتاب: «کل ۵۰»   درست: ۴۰
    بزرگ‌ترین عددِ ۴رقمیِ بی‌تکرار کتاب: ۸۵۳۲     درست: ۹۸۷۶
    g(x) = x³ − ۲              کتاب: «نمایی»   درست: چندجمله‌ای

۲) سوال‌ها فقط از راه‌حل‌های تأییدشده

۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفته‌اند. هر شش پایه برنامه‌ی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.

۳) GET /api/nodes/{id}/quiz

واحدِ بازی در اپ «مبحث» است ولی سوال‌ها به «درس» بسته‌اند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجه‌اش این بود که اپ برای یک
آزمونِ چهارسوالی باید ده‌ها درخواست می‌زد. این اندپوینت از همه‌ی درس‌های مبحث
نمونه برمی‌دارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.

درهم‌ریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را می‌دهد،
نه مجموعه‌ی تازه‌ای که پیشرفت را بی‌معنا کند. `questionCount` جدا از
`requested` می‌آید تا مبحثِ کم‌سوال وانمود نکند کامل است.

──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:

• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته می‌شد و پیش‌فرضِ ۵
  ثانیه‌ایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاک‌سازیِ نقاطِ نیمه‌نوشته: upsert با wait=True ممکن است نوشته باشد و فقط
  جوابش تایم‌اوت شده باشد؛ مسیرِ جبران فقط ردیف‌های DB را پس می‌گرفت و هر
  retry یک نسخه‌ی تکراری در Qdrant جا می‌گذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله می‌آید نه زیرخط، و
  محافظِ اول نمی‌گرفتش — یک اجرا ۷۷۰ فراخوانیِ محکوم‌به‌شکست زد.
• پرامپتِ vision حالا می‌خواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
  محتوای موجود به شکلی ارجاع می‌دهد که در متن نیست؛ مدل آن را دیده بود ولی
  از او خواسته نشده بود بنویسدش. کتاب‌های موجود قبل از این قاعده وارد شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:11:15 +03:30
92dc2242f8 take the points back too when an upsert fails
بررسیِ collectionها بعد از ingestِ پایه نهم نشان داد Qdrant ۲۸۷ نقطه دارد ولی
دیتابیس ۲۷۹ ردیف. هشت نقطه‌ی اضافه، همه از صفحه‌ی ۱۲۵ — همان صفحه‌ای که دو بار
تایم‌اوت داد و بار سوم گرفت. سه مسئله سه نسخه داشتند و دو نقطه هم بی‌صاحب بود.

علت: `upsert` با `wait=True` ممکن است نوشته باشد و فقط جوابش تایم‌اوت شده
باشد. مسیرِ جبران فقط ردیف‌های DB را پس می‌گرفت، نه نقطه‌ها؛ و چون هر اجرای
دوباره `qdrant_point_id` تازه می‌سازد، هر retry یک نسخه‌ی تکراری اضافه می‌کرد.

اثرش در بازیابی دیده می‌شود نه در لاگ: top-k با سه نسخه از یک مسئله پر می‌شود
و بقیه‌ی متنِ مرتبط بیرون می‌ماند — یعنی دستیار بافتِ کمتری می‌بیند و هیچ‌کس
هم متوجه نمی‌شود.

حالا در صورتِ شکست، نقطه‌ها هم پاک می‌شوند. اگر خودِ پاک‌سازی هم شکست بخورد
لاگ می‌شود ولی خطای اصلی پوشانده نمی‌شود، چون صفحه باید ناتمام بماند تا اجرای
بعدی دوباره سراغش برود.

داده‌ی موجود هم تمیز شد: ۸ نقطه‌ی بی‌صاحب پاک شدند و حالا ۲۷۹ = ۲۷۹.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:00:09 +03:30
1131569b76 ground grade 9's questions in the textbook, and catch the book's own errors
پایه نهم تا دیروز ناقص بود: ۲۸ صفحه از کتاب ingest نشده بود و فصل ۸ (حجم و
مساحت) اصلاً وجود نداشت. حالا ۱۴۳ از ۱۴۳ صفحه رفته و برنامه‌ی درسی ۸ فصل،
۲۸ مبحث و ۲۵۸ درس دارد.

۱۷۱ سوالِ تازه از روی مسئله‌های واقعیِ همان کتاب ساخته شد (جمعِ فایل: ۳۳۹).
برخلافِ سوال‌های نمونه‌ی قبلی که از عنوانِ مبحث ساخته شده بودند، این‌ها به
مسئله‌ی مشخصی در کتاب وصل‌اند.

مهم‌تر از خودِ سوال‌ها، چیزی که وسطِ کار معلوم شد: **بخشی از راه‌حل‌هایی که
vision از PDF استخراج کرده غلط است.** ۱۷ مورد پیدا شد، از جمله:

    شمارنده‌های ۶۰ که نه اول‌اند و نه مرکب → کتاب: {۱, ۶۰}   (۶۰ مرکب است)
    x−y=۵ و y=x−۳ → کتاب: (۸,۵)            (خطوط موازی‌اند، بی‌جواب)
    نور در ۱۰۰ ساعت → کتاب: ۱۰۸×۱۰¹³        (ده برابر؛ درست ۱٫۰۸×۱۰¹⁴)
    |۱−√۳| → کتاب: ۱−√۳                     (منفی است؛ درست √۳−۱)
    استوانه ۶۰cm×۱m → کتاب: ۲۸۲٫۷ m³        (خطای واحد؛ درست ۰٫۲۸۳)

اجرای قبلی با gpt-4o-mini همین جواب‌های غلط را بی‌صدا به‌عنوانِ گزینه‌ی درست
علامت می‌زد، چون فقط بازنویسی می‌کرد. مسیرِ تازه اول مسئله را مستقل حل می‌کند
و بعد با راه‌حلِ کتاب مقایسه می‌کند؛ هر اختلاف علامت می‌خورد و دستی بررسی شد.

۷۵ مسئله عمداً سوال نشدند — به شکل یا جدولی در کتاب ارجاع می‌دادند که در متن
نیست. حدس زدن در این موارد یعنی سوالِ بی‌جواب.

`seed_content --export` هم دیگر نمی‌تواند سوال‌ها را بی‌صدا پاک کند: فایل
دست‌نویس است و در دیتابیسِ هر ماشینی نیست، و یک export ساده آن را با آرایه‌ی
خالی بازنویسی می‌کرد. حالا خطا می‌دهد و `--only curriculum` را پیشنهاد می‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:40:09 +03:30
0c374fed99 default the env template to DEBUG off
`.env.example` روی هر دو ماشین کپی می‌شود — لپ‌تاپِ توسعه‌دهنده و سرور — و
مقدارش 1 بود. همان فایل روی سرورِ عمومی نشست و ماه‌ها آنجا ماند، با کدِ
ورودِ 0000 باز برای هر شماره‌ای.

پیش‌فرض باید حالتی باشد که فراموش کردنش بی‌خطر بماند: 0 روی لپ‌تاپ فقط یعنی
خطاها خلاصه‌ترند، ولی 1 روی سرور یعنی هر کسی می‌تواند به‌جای هر کاربری وارد
شود. هزینهٔ این دو اشتباه قابلِ مقایسه نیست.

DEBUG جای دیگری هم تعریف نشده: `DJANGO_DEBUG` فقط از محیط خوانده می‌شود و
`.env` در gitignore است، پس هیچ کامیتی حالتِ هیچ ماشینی را عوض نمی‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:58:33 +03:30
4857f49f92 collect static files even when DEBUG is on
پنلِ ادمین روی سرور بدونِ CSS بالا آمد و هیچ خطایی هم در لاگ نبود.

علت ترتیبِ کارها بود: این مرحله وقتی DEBUG روشن بود رد می‌شد، چون در آن حالت
whitenoise از finders می‌خواند و جمع‌آوری لازم نیست. کسی با DEBUG روشن دیپلوی
کرد و بعد DEBUG را خاموش کرد — کارِ درستی هم بود — و از آن لحظه جنگو دنبالِ
فایل‌هایی می‌گشت که هیچ‌وقت جمع نشده بودند.

یعنی درستیِ دیپلوی به ترتیبِ کارِ یک آدم گره خورده بود. چند ثانیه صرفه‌جویی،
به قیمتِ شکستی که فقط در مرورگر دیده می‌شود. حالا بی‌قید و شرط اجرا می‌شود؛
idempotent است و در هر دو حالت درست کار می‌کند.

staticfiles/ هم به gitignore اضافه شد: حالا که این مرحله همیشه اجرا می‌شود،
روی ماشینِ هر توسعه‌دهنده‌ای ساخته می‌شود و ۴٫۴ مگابایت فایلِ تولیدی نباید
با یک `git add .` واردِ مخزن شود.

بررسی شد که با DEBUG=False کلِ مسیر کار می‌کند: استاتیک ۲۰۰، صفحه‌ی ادمین
۲۰۰، API ۲۰۰، و ورود با OTP از پنل. کدِ 0000 همان‌طور که باید، رد می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:53:55 +03:30
ca983d237f give every topic a starter question so the app can be clicked through
تا امروز کوریکولوم بود و سوال نبود: هر ۱۴ سوالِ دیتابیسِ لوکال روی ردیف‌های
[DEMO] نشسته بودند و در export کنار گذاشته می‌شدند. یعنی سینیور می‌توانست در
مرورگرِ محتوا پایین برود ولی هیچ درسی آزمونی نداشت — که از «محتوا نداریم»
بدتر است، چون شبیهِ خرابی به‌نظر می‌رسد.

حالا هر ۱۶۸ مبحث دستِ‌کم یک سوالِ چهارگزینه‌ای دارد: ۲۳ برای پایه نهم، ۴۶ ششم،
۴۶ هفتم، ۵۳ سوم. هر سوال به درسی وصل است که عنوانش همان موضوع را می‌گوید، نه
به یک درسِ دلخواه.

جای گزینهٔ درست با seed ثابت جابه‌جا شده (۴۴/۴۹/۳۵/۴۰ در چهار موقعیت). اگر
جوابِ درست همیشه گزینهٔ اول باشد، آزمون دیگر چیزی را نمی‌سنجد و آمارِ
«درصدِ غلط» هم بی‌معنا می‌شود. یک تست همین را نگه می‌دارد.

این‌ها نمونه‌اند نه محتوای نهایی: یک سوال برای هر مبحث فقط نشان می‌دهد مسیرِ
درس → آزمون → XP کار می‌کند. سوال‌های واقعیِ هر درس کارِ
`generate_questions_from_chunks` است که به اعتبارِ AI نیاز دارد، و از پنلِ
ادمین هم می‌شود دستی اضافه کرد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:42:14 +03:30
9c3db68cbb carry the curriculum in the repo so a fresh server is not empty
سرورِ production از روزِ اول `{"grades": []}` برگردانده: دیتابیس gitignore است
(درست هم هست) و تنها راهِ پر کردنش اجرای `rag_import_collection` و بعد
`seed_curriculum_from_textbook` روی همان سرور بود — یعنی وابستگی به Qdrant، به
اعتبارِ AI که تمام شده، و به کسی که SSH دارد. نتیجه این شد که هیچ‌کس نتوانست
چیزی را تست کند.

حالا برنامه‌ی درسی در `content/seed/curriculum.json` کامیت می‌شود (۴ پایه، ۳۱
فصل، ۱۶۸ مبحث، ۱۳۰۰ درس، ۵۲۷ کیلوبایت متن) و `deploy.sh` خودش بالا می‌آوردش.
سرورِ تازه نه Qdrant می‌خواهد نه ترمینال.

`db.sqlite3` عمداً کامیت نمی‌شود: باینری است، دیف نمی‌شود، و هر دیپلوی محتوای
سرور — و فردا پاسخ‌های کاربران — را با نسخه‌ی لوکالِ یک نفر جایگزین می‌کند.

سوال‌ها با مسیرِ عنوان ذخیره می‌شوند نه با lessonId. این تصمیمِ اصلیِ کامیت
است: لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ دیتای
دمو)، و همان کوریکولوم روی دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ می‌گیرد. با id،
سوالِ درسِ ۳ روی سرور به درسِ بی‌ربطی می‌چسبید — بدونِ هیچ خطایی. تست
`test_question_follows_the_title_path_not_the_id` همین را نگه می‌دارد.

`seed_content` ایدمپوتنت است و هیچ‌چیز پاک نمی‌کند: کوریکولوم با عنوان
هم‌سان‌سازی می‌شود و سوالِ تکراری رد می‌شود (`skip_existing`). روی سروری که
کاربرِ واقعی دارد، دیپلوی نباید بتواند محتوا یا پاسخی را از بین ببرد — و
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بی‌آنکه دیپلویِ بعدی برش گرداند.

ردیف‌های `[DEMO]`/`[SEED]` موقعِ export کنار گذاشته می‌شوند؛ آن‌ها همان‌هایی‌اند
که `purge_demo_data` برای پاک کردنشان نوشته شد و جایشان در فایلی که مستقیم روی
production لود می‌شود نیست.

هنوز صفر سوالِ واقعی داریم: هر ۱۴ سوالِ لوکال رویِ ردیف‌های دمو بودند. مسیرِ
سوال‌ها ساخته و تست شده، ولی تا وقتی اعتبارِ AI شارژ نشود فایلش خالی می‌ماند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:20:26 +03:30
f3be99e4e2 record where this project is heading, not just how it works
هر ایجنتِ تازه ساختارِ کد را می‌بیند ولی نمی‌داند صاحبِ پروژه دنبالِ چیست:
دو ریپو و یک قراردادِ API (نه ادغامِ ریپوها)، حذفِ هر قدمِ دستی از انتشار،
محتوای قابلِ ویرایش بدونِ برنامه‌نویس، و سنجش‌پذیر بودنِ کیفیتِ پاسخِ AI.

بدونِ این، پیشنهادها به سمتی می‌روند که با مسیرِ پروژه نمی‌خواند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:44:03 +03:30
8b2a92dd3c write down how the two halves connect
هر بار کسی تازه به این پروژه می‌رسد، همان سه چیز را از نو کشف می‌کند: اینکه
فرانت مسیرهای نسبی صدا می‌زند و nginx به‌جای ۴۰۴ صفحه‌ی اپ را برمی‌گرداند،
اینکه ترتیبِ بالا آوردن مهم است، و اینکه کلاینت را نباید از روی حافظه نوشت.

همه در CLAUDE.md نوشته شد، نه در فایلِ تازه — همان‌جایی که خوانده می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:39:40 +03:30
2218f82938 give topic cards a colour before anyone picks one
رنگ و آیکونِ مبحث تازه اضافه شده بودند و هنوز هیچ‌کس پرشان نکرده، یعنی
کارت‌ها تا وقتی کسی از پنلِ ادمین وقت بگذارد بی‌رنگ می‌ماندند.

حالا وقتی فیلد خالی است، مقدارِ پیش‌فرض از پالت و آیکون‌های خودِ اپ می‌آید
(همان رنگ‌های طرح و همان آیکون‌هایی که از lucide وارد می‌شوند)، تا بومی به
نظر برسد نه وصله.

چرخشی بر اساسِ جایگاهِ مبحث در پایه است، نه تصادفی: رنگی که بینِ دو بارگذاری
عوض شود بدتر از بی‌رنگ بودن است، و دو مبحثِ کنارِ هم نباید یک‌رنگ دربیایند.
مقدارِ صریحِ ادمین همیشه برنده است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:10:50 +03:30
10ad1e232c let the subscription card come from the server
کارتِ اشتراک زیرِ هر پلن فهرستِ امکانات و یک برچسب («محبوب‌ترین»، «بهترین
ارزش») نشان می‌دهد، و هر دو در کدِ فرانت هاردکد بودند — یعنی عوض کردنِ یک
بندِ متنی به دیپلویِ تازه‌ی اپ نیاز داشت.

Plan حالا features و badge دارد. `popular` سرِ جایش ماند: بولین است و متنِ
برچسب را نمی‌دهد، پس این جایگزینش نیست، کنارش است. هیچ‌کدام اجباری نیستند و
پلن‌های موجود بدونشان همچنان سرو می‌شوند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:03:15 +03:30
99a0cafe24 serve what the frontend screens actually need
صفحه‌های فرانت خوانده شدند و با API مقایسه شدند. ساختارِ ما از کتابِ واقعی
درآمده و درست‌تر است، پس مدل عوض نشد — ولی چند چیز کم داشت.

مهم‌ترینش یک باگ بود: /api/home پارامتر را `grade` می‌خواند و کلاینتی که به
فرانت فرستادیم `gradeId` می‌فرستد. پارامترِ ناشناخته بی‌صدا نادیده گرفته
می‌شد، یعنی کاربر پایه‌ای را انتخاب می‌کرد و نقشه‌ی پایه‌ی دیگری می‌دید
بدونِ هیچ خطایی. حالا هر دو نام پذیرفته می‌شوند (`gradeId` نامِ درست است،
با بقیه‌ی API یکی). تستِ سرتاسریِ قبلی این را نگرفته بود چون home را بدونِ
آرگومان صدا زده بود.

/api/grades حالا lessonCount می‌دهد. فهرستِ پایه‌های فرانت تا دوازدهم است
ولی محتوای ما تا نهم؛ بدونِ این عدد، کاربر پایه‌ی خالی را انتخاب می‌کند و
صفحه‌ی خالی می‌بیند بی‌آنکه بفهمد چرا.

GET /api/nodes/{id}/lessons اضافه شد. /api/home فقط شناسه‌ی درس‌ها را
می‌دهد، ولی نقشه‌ی مهارت برای هر قدم عنوان، نوع، زیرنویس و «انجام شد» لازم
دارد — بدونش فرانت باید برای هر درس یک intro جدا می‌گرفت.

Lesson حالا kind (آزمون/ویدیو) و متنِ سه‌تکه دارد: توضیح، مثال، حلِ
گام‌به‌گام. ویدیو از قبل سوالِ بازِ محصول بود و فرانت آیتمش را دارد؛ حالا
API می‌تواند بیانش کند، حتی اگر محتوایش بعداً بیاید. Unit یک blurb گرفت و
Node آیکون و رنگ، تا فرانت رنگ‌ها را هاردکد نکند. هیچ‌کدام اجباری نیست، پس
محتوای موجود دست‌نخورده کار می‌کند.

PATCH /api/user/profile حالا age را هم می‌پذیرد (صفحه‌ی ثبت‌نامِ فرانت سن
می‌پرسد). ذخیره‌سازی همچنان birthYear است چون کهنه نمی‌شود؛ این فقط ورودی
را ترجمه می‌کند تا دو طرف حسابِ متفاوت درنیاورند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:42:24 +03:30
8effe5b60b secure the admin session and make traffic visible from outside
دو شکاف که وقتی آدم‌های دیگر به پروژه وصل می‌شوند مهم‌تر می‌شوند.

کوکیِ نشستِ پنلِ ادمین و توکنِ CSRF بدونِ پرچمِ secure رد و بدل می‌شدند.
احرازِ هویتِ خودِ API کوکی نیست و این‌ها فقط برای ادمین‌اند — یعنی دقیقاً
همان‌جایی که اعتبارنامه‌ی واقعی وارد می‌شود. حالا خارج از DEBUG امن‌اند، و
SECURE_PROXY_SSL_HEADER هم اضافه شد چون TLS روی nginx تمام می‌شود و بدونش
جنگو درخواست را HTTP می‌بیند و کوکیِ امن اصلاً ست نمی‌شود.

SECURE_SSL_REDIRECT و HSTS عمداً روشن نشدند: اولی پشتِ پراکسیِ بدتنظیم
حلقه‌ی ریدایرکت می‌سازد و کلِ سایت را می‌خواباند، دومی در مرورگرِ کاربر کش
می‌شود و عملاً برگشت‌ناپذیر است. هر دو تصمیمِ زیرساختی‌اند نه کد.

/api/health حالا ترافیک را هم گزارش می‌دهد: تعدادِ کاربر، فعال‌های ۲۴ ساعتِ
اخیر، و زمانِ آخرین فعالیت. تا امروز هیچ راهی نبود از بیرون بفهمیم فرانت
واقعاً به سرور رسیده یا نه — باید منتظر می‌ماندیم کسی بگوید. فقط شمارش و
زمان؛ تست جداگانه بررسی می‌کند هیچ شماره یا نامی بیرون نرود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:20:02 +03:30
39a9f435ac make the deploy script survive an unfamiliar server
دو حالتی که فقط روی سرورِ واقعی پیش می‌آیند، نه اینجا.

اسکریپت‌های قدیمیِ activate به PS1 بدونِ محافظ دست می‌زنند و زیرِ set -u با
«unbound variable» کلِ دیپلوی را می‌خوابانند — venv اینجا سالم است ولی venv
سرور لزوماً نه، پس set -u دورِ همان یک خط موقتاً خاموش می‌شود.

بررسیِ سلامت فقط 127.0.0.1:8000 را می‌زد. اگر gunicorn روی سوکت یا پورتِ
دیگری باشد، دیپلویِ کاملاً موفق هم با پیامِ خطا تمام می‌شد. حالا آدرسِ
عمومی هم امتحان می‌شود و در پایان می‌گوید کدام آدرس‌ها را زده.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:53:43 +03:30
a3a47b459d stop an existing .env from cancelling the defaults meant to replace it
پیش‌فرض‌های دیروز روی یک سرورِ تازه کار می‌کردند ولی روی سرورِ واقعی نه، و
تستِ با .env واقعی نشانش داد: هر دو حالتِ شایع خنثی‌شان می‌کرد.

مقدارِ موجود ولی ناقص: .env سرور از قبل
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد. چون مقدار خالی نبود، کاملاً
جایگزینِ پیش‌فرض می‌شد و دامنه‌ی api.math.tadrisino2.ir باز هم غایب می‌ماند —
یعنی همان «Disallowed Host» قبلی، و باز هم ویرایشِ دستیِ فایل. حالا
دامنه‌های خودمان به مقدارِ محیطی اضافه می‌شوند نه جایگزینش؛ همین برای
CSRF_TRUSTED_ORIGINS و CORS_ALLOWED_ORIGINS هم اعمال شد.

مقدارِ موجود ولی خالی: PUBLIC_BASE_URL در .env هست و خالی است، و
os.environ.get فقط وقتی کلید *نباشد* پیش‌فرض می‌دهد. نتیجه‌اش paymentUrl
نسبی و callbackِ خالیِ زرین‌پال بود — خرابی‌ای که فقط موقعِ اولین پرداختِ
واقعی معلوم می‌شود. حالا مقدارِ خالی هم به پیش‌فرض می‌افتد.

این سه‌تا همان‌هایی‌اند که تا امروز مسئولِ سرور باید دستی به .env اضافه
می‌کرد و چون در .env.example هم نبودند، هر بار از نو کشف می‌شدند. حالا در
همان فایل مستند شده‌اند و تست نگهبانشان است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:00:35 +03:30
3dacf3a50f fix two checks that only broke in the case they exist for
بازبینیِ کارِ امروز، سه مورد پیدا کرد.

/api/health وقتی مهاجرتِ اعمال‌نشده وجود داشت می‌شکست: کدِ فهرست‌کردنِ
مهاجرت‌ها به migration.key تکیه می‌کرد که اصلاً وجود ندارد (فیلدها app_label
و name هستند). یعنی دقیقاً در همان حالتی که این بررسی برایش ساخته شده،
به‌جای نامِ مهاجرت‌ها فقط AttributeError برمی‌گرداند. حالا تستی دارد که
مهاجرتِ معلق را شبیه‌سازی می‌کند — چیزی که نبودش باعث شد اولین بار غلط بماند.

وارد کردنِ سوال با replace عددِ غلط گزارش می‌کرد: delete() در جنگو مجموعِ
همه‌ی ردیف‌های آبشاری را می‌دهد، پس «۵ سوال پاک شد» در واقع گزینه‌ها و
پاسخ‌ها را هم می‌شمرد.

deploy.sh بررسیِ متغیرهای محیطی‌اش با تنظیماتِ جدید ناسازگار شده بود:
متغیرهایی را اجباری می‌دانست که حالا پیش‌فرض دارند، و فقط شل را می‌خواند نه
.env — یعنی روی سرورِ درست هم دیپلوی را رد می‌کرد. حالا به‌جای تکرارِ منطقِ
تنظیمات، از خودِ جنگو می‌پرسد چه چیزی مؤثر است و گزارش می‌دهد.

کوچک‌تر: بررسیِ سلامت یک درخواست می‌زند نه دو تا، و شکستِ ری‌استارت دیگر
اسکریپت را بی‌صدا تمام نمی‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:42:36 +03:30
e5d2427560 keep one env example instead of two
دو فایلِ نمونه یعنی دو جا که باید هم‌زمان به‌روز بمانند، و همان چیزی بود که
قبلاً باعث شد نمونه عقب بماند و کسی که سرور را بالا می‌آورد نداند چه بنویسد.
حالا یک فایل، هم برای ماشینِ محلی هم برای سرور.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:15:42 +03:30
3e31fea42e decide in the repo what the server should not have to guess
فایلِ نمونه‌ی قبلی هنوز چند جای خالی داشت که کسی باید پرشان می‌کرد، و دو بار
همین باعثِ رفت‌وبرگشت شد. حالا فقط چیزهایی جای خالی‌اند که واقعاً فقط آن طرف
می‌داند: کلیدهای سرویس‌های بیرونی و نامِ سرویسِ systemd.

بقیه از قبل تصمیم گرفته شده‌اند و اگر فایل خالی هم باشد سرویس بالا می‌آید.
حالتِ پیش‌فرض عمداً مرحله‌ی توسعه است (DEBUG=1) با ریسک‌هایش نوشته، نه
پروداکشن — چون وضعیتِ امروزِ پروژه همین است و فایلی که وضعیتِ واقعی را
توصیف نکند دوباره حدس‌زدنی می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:09:12 +03:30
37d66a4a4c give content editors a drill-down browser and per-question signals
پیدا کردنِ «سوال‌های فلان مبحث» در فهرستِ پیش‌فرضِ جنگو عملاً ممکن نبود: باید
بینِ چند صفحه‌ی جدا با دراپ‌داون‌های هزارتایی بالا و پایین می‌رفتی.

/admin/content/question/browse/ چهار ستونِ آبشاری دارد — پایه → فصل → درس →
مبحث — و هر ستون فقط وقتی پر می‌شود که قبلی انتخاب شده باشد، پس هیچ‌وقت
فهرستِ هزارتایی جلوی چشم نیست. ته‌اش سوال‌های همان مبحث با گزینه‌هایشان
می‌آیند و از همان‌جا ویرایش یا اضافه می‌شوند.

کنارِ هر سوال دو عددِ تصمیم‌ساز: گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط.
درصد زیرِ ۵ پاسخ نشان داده نمی‌شود — یک نمونه‌ی کوچک سوالِ سالم را
«مشکل‌دار» جلوه می‌دهد. همین دو ستون به فهرستِ سوال‌ها هم اضافه شدند.

content.QuestionReport و POST /api/questions/{id}/report تازه‌اند: تنها
بازخوردی که تا امروز جمع می‌شد مالِ چتِ دستیار بود، و روی سوال‌های مسیرِ
آموزشی هیچ راهی نبود که کاربر بگوید گزینه‌ی درست غلط است. گزارشِ بازِ تکراریِ
همان کاربر ردیفِ تازه نمی‌سازد تا دابل‌کلیک آمار را به‌هم نزند.

وابستگیِ تازه‌ای اضافه نشد: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:08:00 +03:30
67361f6711 boot with sane defaults instead of refusing to start
سه بار پشتِ سر هم همین الگو تکرار شد: یک متغیرِ محیطی ست نبود، جنگو موقعِ
بالا آمدن مرد، و از بیرون فقط «502 Bad Gateway» دیده شد. کسی که سرور را بالا
می‌آورد هیچ سرنخی نداشت، و تشخیص هر بار یک رفت‌وبرگشتِ یک‌روزه بود.

ALLOWED_HOSTS و CSRF_TRUSTED_ORIGINS حالا دامنه‌های شناخته‌شده‌ی خودمان را
پیش‌فرض دارند. CSRF مخصوصاً مهم است: بدونش ورود به پنلِ ادمین از پشتِ پراکسیِ
HTTPS «CSRF verification failed» می‌دهد و علتش از پیام معلوم نیست.

SECRET_KEY اگر ست نباشد، در پروداکشن یک کلیدِ تصادفیِ واقعی ساخته و در
.django_secret_key نگه داشته می‌شود (۰۶۰۰، خارج از گیت). همان‌قدر
غیرقابلِ حدس، بدونِ یک قدمِ دستیِ دیگر؛ و چون روی دیسک می‌ماند، ری‌استارت
نشست‌های کاربران را باطل نمی‌کند.

CACHE_URL نبودن دیگر مانعِ بالا آمدن نیست. مشترک نبودنِ کش بینِ workerها یک
ضعفِ واقعی‌ست ولی دلیلِ خوبی برای خوابیدنِ کلِ سرویس نیست؛ حالا بالا می‌آید و
/api/health صریحاً هشدار می‌دهد. تستِ مربوطه هم از «باید بالا نیاید» به
«باید بالا بیاید ولی بی‌صدا نماند» تغییر کرد.

متغیرِ محیطی همچنان بر همه‌ی این پیش‌فرض‌ها اولویت دارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:08:00 +03:30
9cee574fa9 put the whole content pipeline behind the admin panel
سوال‌ها از قبل فله‌ای وارد می‌شدند ولی دو شکاف مانده بود: افزودنِ تکی عملاً
غیرقابلِ استفاده بود، و خودِ برنامه‌ی درسی هیچ راهی جز ترمینالِ سرور نداشت.

افزودنِ تکیِ سوال: انتخابِ درس یک دراپ‌داونِ ۱۳۰۷تایی بود. حالا autocomplete
است و بر اساسِ عنوانِ درس، فصل و پایه جستجو می‌شود.

برنامه‌ی درسی (Grade → Unit → Node → Lesson) حالا از /admin/content/grade/
وارد و خارج می‌شود. تا امروز رساندنش به سرور یعنی اجرای rag_import_collection
و بعد seed_curriculum_from_textbook روی همان سرور — یعنی وابستگی به Qdrant و
به آدمی با دسترسیِ SSH. حالا یک‌بار لوکال ساخته می‌شود و فایلش بالا می‌رود.

هم‌سان‌سازی با عنوان است نه با id: idها بینِ دو دیتابیس یکی نیستند و تکیه بر
آن‌ها یعنی وارد کردن روی سرور محتوای بی‌ربط را بازنویسی می‌کند.

وارد کردنِ برنامه‌ی درسی عمداً چیزی پاک نمی‌کند و idempotent است — یک فایلِ
ناقص نباید محتوای سرور را از بین ببرد، و اجرای دوباره نباید چیزی را دو بار
بسازد. (وارد کردنِ سوال‌ها اما replace دارد، چون جایگزینیِ سوال‌های یک درس
کارِ رایجی است.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:46:50 +03:30
eaa4b761dd stop a deploy from silently blocking the frontend or leaving 0000 open
سه حالتِ خراب که هیچ‌کدام در لاگِ سرور ردی نمی‌گذارند و فقط وقتی کشف می‌شدند
که یک آدم از بیرون اپ را دستی امتحان کند.

CORS_ALLOWED_ORIGINS فقط از env خوانده می‌شد و با DEBUG روشن به localhost
محدود می‌ماند. یعنی بک‌اندِ کاملاً سالم، ولی مرورگر همه‌ی درخواست‌های
math.tadrisino2.ir را بلاک می‌کند — خطایی که سمتِ سرور دیده نمی‌شود و فقط در
کنسولِ مرورگر پیداست. origin های شناخته‌شده حالا پیش‌فرضِ داخلِ کدند تا یک
متغیرِ فراموش‌شده اتصال را نشکند؛ env همچنان بازنویسی‌شان می‌کند.

/api/health حالا warnings هم می‌دهد. مهم‌ترینش: با DEBUG روشن روی سرورِ عمومی،
DEV_MASTER_OTP یعنی هر کسی با کدِ 0000 می‌تواند به‌جای هر شماره‌ای وارد شود.
این تا امروز فقط با خواندنِ تنظیماتِ سرور معلوم می‌شد، و ما به سرور دسترسی
نداریم. allowedOrigins هم برگردانده می‌شود تا مشکلِ CORS از بیرون قابلِ
تشخیص باشد.

deploy.sh قبل از دست زدن به دیتابیس متغیرهای لازم را بررسی می‌کند و با پیامِ
روشن می‌ایستد، به‌جای اینکه وسطِ کار بشکند و 502 بدهد.
.env.production.example همان‌ها را یک‌جا مستند می‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:46:23 +03:30
190bff3d0f manage questions from the admin panel, not the server terminal
تا الان تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ همان سرور
بود. یعنی هر تغییرِ محتوایی به کسی که SSH دارد وابسته می‌شد، تاریخچه نداشت و
برگشت‌پذیر نبود. محتوا داده است نه کد؛ باید با رابطِ کاربری ویرایش شود.

در /admin/content/question/ دکمه‌ی «وارد کردنِ سوال‌ها از فایل» اضافه شد، و
اکشنِ «دریافتِ فایلِ سوال‌های انتخاب‌شده» خروجی می‌دهد. خروجی دقیقاً همان قالبی
است که ورودی می‌پذیرد، پس چرخه‌ی «بگیر → ویرایش کن → برگردان» بسته است و برای
گرفتنِ نمونه هم لازم نیست کسی مستندات بخواند.

قالب عمداً ساده است (lessonId، prompt، choices، correct) تا کسی که برنامه‌نویس
نیست هم بتواند در ویرایشگرِ متن درستش کند.

اعتبارسنجی قبل از هر نوشتنی کامل انجام می‌شود و کلِ فایل در یک تراکنش است:
فایلِ نامعتبر هیچ چیزی نمی‌نویسد. وارد کردنِ نیمه‌کاره بدترین حالت بود، چون
کسی که فایل را داده فکر می‌کند کار شده ولی درس نصفه پر است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:29:24 +03:30
cbe9b63dc4 make a deploy observable and one command instead of a checklist
بالا آوردنِ نسخه‌ی جدید تا الان چند کارِ دستی به ترتیبِ درست بود (pull، نصبِ
وابستگی‌ها، مهاجرت، استاتیک، ری‌استارت) و فراموش شدنِ هر کدام اپ را می‌خواباند
با خطایی که از بیرون فقط «502 Bad Gateway» دیده می‌شد. همین الان هم سرور
دقیقاً به همین دلیل خوابیده: کامیتِ ac1b1af پکیجِ django-cors-headers را اضافه
کرد، و بدونِ pip install جنگو با ModuleNotFoundError بالا نمی‌آید.

deploy.sh همه را به ترتیب و idempotent انجام می‌دهد. pip install همیشه اجرا
می‌شود، نه فقط وقتی requirements عوض شده — تشخیصِ «عوض شده یا نه» خودش یک جای
فراموش‌شدنیِ دیگر است. آخرِ کار خودش سلامت را بررسی می‌کند و اگر بالا نیامده
باشد علت‌های رایج را چاپ می‌کند، به‌جای اینکه کسی بعداً از بیرون کشفش کند.

GET /api/health وضعیت را بدونِ احراز و بدونِ SSH بیرون می‌دهد: دیتابیس،
مهاجرت‌های اعمال‌نشده، کش، و شمارشِ پایه/درس/سوال روی همان سرور. مهم‌ترینش
مهاجرتِ اعمال‌نشده است — حالتی که اپ ظاهراً بالا می‌آید و بعد زیرِ بارِ واقعی
با خطای ستونِ ناموجود می‌شکند. عمداً هیچ سکرتی برنمی‌گرداند، فقط بله/خیرِ
«تنظیم شده».

۵۰۳ برمی‌گرداند وقتی دیتابیس/کش/مهاجرت خراب است تا پایشِ خودکار بتواند رویش
حساب کند؛ نبودِ محتوا خرابی حساب نمی‌شود ولی گزارش می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:28:59 +03:30
5f3aae0756 cover the new paths, especially the ones that fail silently
خطاهای این پروژه خاموش‌اند: نامِ اشتباهِ collection یا فیلترِ نخورده نتیجه‌ی
خالی می‌دهد نه استثنا، و دستیار فقط بدتر جواب می‌دهد. این تست‌ها نگهبانِ
همان‌جاهایند.

TextbookLayoutTests بررسی می‌کند رشته‌ی هر فصل بینِ ingest و retrieval یکی
باشد و بازه‌ی صفحه‌ها پیوسته بماند. IngestBookResumeTests مطمئن می‌شود
صفحه‌ی بی‌مسئله دوبار پول نمی‌گیرد، صفحه‌ی خطادار در اجرای بعد دوباره
سراغش می‌روند، و upsertِ شکست‌خورده rollback می‌شود.
LessonContextResolutionTests نگهبانِ نرمال‌سازیِ «پایه نهم» → «ریاضی نهم»
است و اینکه مسیرِ gradeId و مسیرِ lessonId به یک collection برسند.
GradeAwarePromptTests جلوی برگشتنِ لحنِ دبستانی برای پایه‌های بالا را
می‌گیرد. FollowUpStaysGroundedTests پیامِ دومِ یک گفتگو را هم مستند نگه
می‌دارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:01:24 +03:30
ac1b1af398 open the API up to the frontend
سینیور خواست زودتر به فرانت‌اند وصل شویم تا از آنجا هم تست شود. این‌ها
سمتِ ما لازم بودند:

CORS با allowlist صریح (نه ALLOW_ALL): احرازِ هویتِ ما توکنِ Bearer در هدر
است، پس origin ناشناس نباید از مرورگرِ کاربر به API ما بزند.
CORS_ALLOW_CREDENTIALS عمداً خاموش می‌ماند — کوکی‌ای در کار نیست و روشن
کردنش فقط سطحِ حمله اضافه می‌کند. CorsMiddleware بالاتر از CommonMiddleware
گذاشته شد وگرنه ریدایرکتِ APPEND_SLASH و خطای ۴۰۰ هدرِ CORS نمی‌گیرند و در
مرورگر به‌شکلِ خطای مبهمِ شبکه دیده می‌شوند.

PATCH /api/user/profile اضافه شد؛ تا پیش از این هیچ راهی برای ثبتِ نامِ
کاربر نبود. GET /api/home بدونِ پارامتر حالا پایه‌ی ذخیره‌شده‌ی کاربر را
برمی‌گرداند و POST /api/ai/ask فقط با lessonId کار می‌کند — هر دو برای
اینکه فرانت لازم نباشد پایه را خودش نگه دارد. questionCount به
lessons/{id}/intro اضافه شد.

docs/frontend/api.ts و README.md کلاینتِ تایپ‌دار و راهنمای اتصال‌اند، از
روی OpenAPI واقعیِ همین سرویس تولید شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:01:06 +03:30
fa56308a35 make the tutor grade-aware and its answers measurable
مهم‌ترین خواسته‌ی سینیور این بود که بسنجیم پاسخِ AI واقعاً یاد می‌دهد یا نه.
برای سنجیدن، اول باید پاسخ‌ها قابلِ ردیابی می‌شدند.

پرامپت‌ها پایه‌آگاه شدند: تا پیش از این «معلمِ دبستانی» برای پایه نهم هاردکد
بود، هم در تولیدِ پاسخ و هم در vision — یعنی دانش‌آموزِ نهم لحنِ ابتدایی
می‌گرفت. PROMPT_VERSION حالا روی هر پیام ذخیره می‌شود.

هر پاسخ حاشیه‌نویسی می‌شود: مدل، نسخه‌ی پرامپت، پایه، collection، مقدارِ
فیلتر، تعداد chunk، توکن، تأخیر و from_cache. فیدبک reason اختیاری گرفت تا
لایک/دیسلایک بعداً قابلِ تحلیل باشد («بشه چک کرد کدوم بهتر بوده»).

ai_feedback_report --by prompt_version نسبتِ رضایت را کنارِ مصرف/هزینه
می‌گذارد. rag_eval سوال‌های واقعی را از مسیرِ واقعی می‌پرسد، با
--retrieval-only برای اجرای ارزان.

rag/services/lesson_context.py از lessonId تنها پایه و فصل را درمی‌آورد؛
normalize_grade_title هم اختلافِ «پایه نهم» در دیتابیس با «ریاضی نهم» در
نامِ collection را می‌بندد — بدونِ آن مسیرِ gradeId بی‌سروصدا خالی برمی‌گشت.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:00:40 +03:30
78130e7194 build the real curriculum from the textbook structure
تا پیش از این کلِ محتوای دیتابیس ردیف‌های [DEMO] و [SEED] بود — دانش‌آموزِ
پایه نهم در دمو «جمع تا ۱۰» می‌دید. سینیور «یک بیسیکِ کامل با دیتای واقعی»
خواسته بود.

seed_curriculum_from_textbook (رایگان، هیچ فراخوانیِ پولی ندارد):
    Grade  ← پایه‌ی کتاب
    Unit   ← فصلِ کتاب
    Node   ← درسِ داخلِ فصل، از فهرستِ خودِ کتاب
    Lesson ← نوعِ مسئله‌ی استخراج‌شده در بازه‌ی صفحه‌ی همان درس
intro_text مستقیم از generalSolution کتاب می‌آید.

generate_questions_from_chunks سوالِ چهارگزینه‌ای با گزینه‌های انحرافیِ
باورپذیر می‌سازد (هزینه‌دار، اعتبارسنجیِ سخت‌گیرانه). سوال اینجا جدا شد
چون ساختنِ گزینه‌ی انحرافیِ درست به مدل نیاز دارد.

purge_demo_data ردیف‌های [SEED]/[DEMO] را گزارش و — فقط با --apply — پاک
می‌کند؛ بدونِ آن این ردیف‌ها در دمو داخلِ API دیده می‌شوند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:00:10 +03:30
c79be0a336 ingest whole textbooks chapter by chapter, resumably
نقشه‌ی فیزیکیِ کتاب‌ها (افستِ صفحه، بازه‌ی هر فصل، درس‌های داخلِ فصل از
فهرستِ خودِ کتاب) در rag/services/textbooks.py ثبت شد تا عنوانِ فصل از
کتاب بیاید نه از حدسِ مدل — رشته‌ی فصل باید دقیقاً بینِ ingest و retrieval
یکی باشد وگرنه نتیجه بی‌سروصدا خالی برمی‌گردد.

rag_ingest_book کتاب را فصل‌به‌فصل ingest می‌کند و قابلِ ازسرگیری است:
rag.IngestedPage نشانه‌ی «این صفحه پردازش شد» را نگه می‌دارد حتی وقتی
صفحه chunkی نساخته، وگرنه هر بار اجرای دوباره بابتِ صفحه‌های بی‌مسئله
دوباره پول می‌گرفت. --plan هزینه را رایگان برآورد می‌کند.

rag_import_collection نقاطِ موجودِ Qdrant را در LessonChunk می‌ریزد —
فقط می‌خواند، رایگان و idempotent، برای پایه‌هایی که تیمِ دیگر ingest
کرده بود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:59:03 +03:30
52d9694583 keep ingested chunks findable and structured
Two bugs made self-ingested content useless, both silently:

- _payload wrote `chapter_number` (int) while math retrieval filters on
  `chapter` (the full "فصل ۲: کسر" string), so anything we ingest could
  never match a search — no error, just permanently invisible content.
- ingest_pdf flattened the vision extraction into one text blob and threw
  away type/keywords/generalSolution/problem/solution. LessonChunk is the
  source of truth, so rebuilding meant paying for vision again — and the
  problem/solution pairs are the input for building lesson questions.

LessonChunk now stores those fields and _payload emits them under the same
keys the other team's collections use, so ours and theirs stay one shape.

Also fixes a regression from the whitenoise commit: the Manifest storage
variant raises when staticfiles.json is absent, which broke all 23 admin
rendering tests (Django forces DEBUG=False in tests, so USE_FINDERS is off)
and would have taken the admin panel down entirely the day DEBUG=0 ships
without a collectstatic run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:24:18 +03:30
f0e3e3c96c serve static files via whitenoise under gunicorn
Admin/DRF/Swagger CSS and JS never load under gunicorn since Django's
staticfiles app only auto-serves through `runserver`. WHITENOISE_USE_FINDERS
is tied to DEBUG so no collectstatic step is needed while DEBUG=1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 22:41:04 +03:30
092fcf13f2 add CSRF_TRUSTED_ORIGINS setting for reverse-proxied domains
Admin login (and any POST form) fails with "Origin checking failed"
behind a proxy that terminates TLS, since Django had no trusted-origins
list configured at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 22:26:46 +03:30
37a1741e0e add gamification characters (Duolingo-style mascot catalog)
New progress.Character model: an admin-managed catalog (same pattern as
subscription.Plan) of selectable mascots, each unlocked once the user's
XP crosses unlock_xp. No separate ownership table yet — unlocking is a
pure function of UserStats.xp, kept intentionally minimal since this is
the first of an incremental rollout the senior asked for.

- GET /api/characters — catalog with per-user unlocked/selected flags
- POST /api/characters/select — picks an active character, rejecting
  ones the user hasn't unlocked yet
- UserStats.selected_character_id tracks the pick
- seed_test_data now seeds 4 sample characters (owl/fox/turtle/lion)
  for local/staging testing

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:53:17 +03:30
b5476e1c5d connect Zarinpal as the real payment gateway
payment.services.call_payment_gateway was a stub returning a fake
payment.gateway/pay/{id} URL. It's replaced with a real Zarinpal
integration (payment/zarinpal.py, Payment API v4):

- create_order/CreateOrderView still never call Zarinpal directly —
  paymentUrl points at our own GET /api/payment/orders/{id}/pay
  (PaymentRedirectView), which requests the real Zarinpal authority on
  first visit and redirects to StartPay. This keeps order creation from
  depending on Zarinpal's uptime/latency, and means none of the existing
  purchase/idempotency tests needed to change.
- GET /api/payment/zarinpal/callback (ZarinpalCallbackView) is where
  Zarinpal redirects the user back to; it always re-verifies
  server-to-server via zarinpal.verify_payment before marking an order
  paid, so a forged Status=OK in the query string alone does nothing.
- zarinpal.py fails closed (503 / invalid) when ZARINPAL_MERCHANT_ID
  isn't configured, matching payment/bazaar.py's existing convention —
  never a fake successful payment.

Adds Order.gateway_authority, PUBLIC_BASE_URL/ZARINPAL_* settings, and
a payment_pay throttle scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:27:13 +03:30
a275eac7fd fix heart refill and grant unlimited hearts to active subscribers
next_refill_at was set on UserHearts but never read or recalculated,
so a heart lost to zero never came back. UserHearts.apply_regen() now
catches up missed 24h intervals on read/deduct, and lose_heart() starts
the timer only when it isn't already running.

Any user with an active (non-expired) subscription now skips heart
deduction entirely, regardless of which plan they're on — good enough
for now until per-plan perks are needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:26:49 +03:30
a367685b2e expose per-node lessonIds in GET /api/home
The skill-map response gave units -> nodes with id/title/status but no way
to map a node to its lessons, and no other endpoint fills that gap
(/api/progress/nodes doesn't either). Any client rendering the map needs
this to know which lesson to open when a node is tapped.

The ids are already computed in the cached _grade_tree; this just surfaces
them. Additive and backward-compatible; content tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:04:47 +03:30
4839123698 remove dead RAG tutor module and stale config notes
rag/services/tutor.py
  - deleted: answer_question() had no callers (AskAIView calls
    retrieve_context + generate_answer directly) and carried an unused
    import.
rag/services/config.py
  - docstring: drop the claim that the lesson-filter key is settings-driven
    (it is auto-selected per subject in known_collections.py).
.env.example
  - drop the leftover note about the removed RAG_QDRANT_LESSON_FILTER_KEY.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 13:02:43 +03:30
40b4dd53a9 cache the subscription plans list
subscription/views.py
  - PlansListView: served from cache (subscription:plans:v1, 1h TTL);
    plans change ~never and the list is read on every subscription screen.
subscription/receivers.py (new)
  - post_save/post_delete on Plan -> cache.delete(PLANS_CACHE_KEY).
subscription/apps.py
  - SubscriptionConfig.ready() imports receivers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:46:30 +03:30
d25ecab0b3 optional short-TTL cache for the auth user row
TadrisinoTokenAuthentication ran one SELECT on accounts_user for every
authenticated request. Off by default (AUTH_USER_CACHE_TTL=0) so behaviour
is unchanged until a deployment opts in.

accounts/authentication.py
  - _get_request_user(): when AUTH_USER_CACHE_TTL>0, cache all concrete
    field values under auth:user:<id> and rebuild a full User instance
    (so save(update_fields=[...]) stays safe); otherwise load from DB as
    before. invalidate_user_cache() / _load_user_or_fail() helpers.
accounts/receivers.py (new)
  - post_save/post_delete on User -> invalidate_user_cache(pk); covers
    every in-app write path (SetUserGrade, login, refresh, last_activity).
accounts/apps.py
  - AccountsConfig.ready() imports receivers.
accounts/tests.py
  - AuthUserRowCacheTests: disabled -> loads every request; enabled ->
    loads once then cache; User.save() invalidates; SetUserGradeView still
    persists and the next request sees the new grade.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:46:11 +03:30
ff57adbc5f throttle hot read endpoints and cap unbounded list responses
progress/views.py
  - GuestAwareReadThrottle (same guest-by-IP pattern as the other apps);
    throttle_scope='read' on UserSummaryView, StreakCalendarView,
    LeaderboardView.
notifications/views.py
  - GuestAwareReadThrottle + throttle_scope='read' on NotificationListView.
  - list capped at NOTIFICATIONS_MAX=100 newest (Meta.ordering already
    newest-first); unreadCount still counted over the full set.
notifications/tests.py
  - 150 rows -> 100 returned newest-first, unreadCount = 75.
rag/views.py
  - ChunkListView: explicit order_by('-id') before the existing [:200] cap.
ai_tutor/views.py
  - ConversationMessagesView: return the newest 200 messages in
    chronological order instead of the whole conversation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:45:43 +03:30
35218bf6a1 fix HomeView N+1 and cache the curriculum tree
GET /api/home is the most-hit authenticated endpoint (every app open) and
was running one COUNT per node. Now it is a flat number of queries
regardless of grade size, and the shared curriculum skeleton is served
from cache; per-user progress/stats stay live.

content/views.py
  - _node_status(): takes a precomputed set of completed lesson_ids and a
    node's lesson_ids instead of firing its own COUNT query.
  - HomeView.get(): one query for all completed lesson_ids of the user in
    the grade, status computed in Python; grade tree (units -> nodes ->
    lesson_ids, no per-user data) read from cache
    (content:tree:grade:{id}:v{N}); missing grade -> 404 (was an
    unhandled 500); grade with no units -> empty units, not 404.
  - GradesListView: served from cache (content:grades:v{N}).
  - curriculum_version() / bump_curriculum_version(): one shared version
    key; bumping it invalidates every content cache key at once.
  - both GET views get throttle_scope='read' + GuestAwareContentThrottle.
content/receivers.py
  - post_save/post_delete on Grade/Unit/Node/Lesson -> bump_curriculum_version().
content/tests.py
  - HomeViewScalabilityTests: query count flat for 2 vs 20 nodes; 2nd call
    hits cache (no content_unit/node SQL); a new Node invalidates the tree;
    missing grade 404; empty grade 200.
  - GradesListCacheTests: list cached, invalidated on new Grade.
  - ReadThrottleTests: /api/home returns 429 past the limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:45:18 +03:30
eeb6bd6138 scale prep — persistent DB connections, read throttle, prod renderer, auth-cache knob
settings.py
  - DATABASES.default.CONN_MAX_AGE from DB_CONN_MAX_AGE env (default 60);
    harmless on SQLite, saves a TCP+auth handshake per request on Postgres.
  - DEFAULT_RENDERER_CLASSES: BrowsableAPIRenderer only when DEBUG — no
    HTML API browser or its overhead in production.
  - DEFAULT_THROTTLE_RATES['read'] = READ_THROTTLE_RATE env (default
    120/min): a cheap ceiling for the hot GET endpoints.
  - AUTH_USER_CACHE_TTL env (default 0 = off): TTL for the per-request
    user-row cache added in a later commit; 0 keeps current behaviour.
.env.example
  - document DB_CONN_MAX_AGE / AUTH_USER_CACHE_TTL / READ_THROTTLE_RATE.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:44:50 +03:30
40d358ecb5 readme: add RAG / AI-assistant section
README.md
  - `rag` app row in the app table.
  - new "دستیار هوش مصنوعی (RAG)" subsection: what /api/ai/ask does now,
    the OPENAI_/QDRANT_ keys it needs, how to test it in Swagger, the
    stream flag, rag_catalog, and the internal ingest endpoints.
  - production-notes bullet updated: /api/ai/ask is real RAG when keys are
    set, no longer "stub only".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:39:48 +03:30
149f1ad9ae return mascotMood on answer submission
The math-app frontend prototype drives a RobotMascot with a fixed set of
moods; this gives that animation an explicit backend signal. Complements
the existing Lesson.mascot_animation / LessonIntro.mascotAnimation, which
are unchanged.

serializers.py
  - AnswerResponseSerializer: new mascotMood field.
views.py
  - QuestionAnswerView: mascotMood = 'happy' for a correct answer else
    'encourage', on both the fresh response and the idempotent-replay
    response.
tests.py
  - QuestionAnswerMascotMoodTests: correct -> happy, wrong -> encourage,
    and the mood is preserved across an idempotent replay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:30:50 +03:30
5416fbb2ba real RAG answers on POST /api/ai/ask, plus SSE streaming and answer cache
When RAG is configured the endpoint retrieves lesson-scoped chunks and
generates a grounded answer; otherwise the previous deterministic stub
runs unchanged, so keyless runs and existing tests are unaffected.
Quota / idempotency / user-row lock / clean-503 behaviour is preserved on
every path.

models.py
  - Conversation.subject (CharField) and Conversation.lesson_number
    (IntegerField): retrieval metadata sent on the first ask and reused
    for the rest of the conversation.
migrations/0004_conversation_lesson_number_conversation_subject.py
  - migration for the two new fields.
serializers.py
  - AskRequestSerializer: optional subject / grade / lessonNumber to pin
    the Qdrant collection and lesson/chapter.
  - AskResponseSerializer: usedChunks (retrieved [{title, summary}]) and
    retrievalDebug (collection / filterKey / chapter-or-lesson / counts,
    emitted only under DEBUG).
views.py
  - generate_tutor_answer(): unchanged signature; RAG-configured branch
    calls rag.services generation, stub branch otherwise.
  - AskAIView.post(): resolve subject/grade/lessonNumber (falling back to
    the Conversation), check the answer cache via resolve_target(), run
    retrieve_context + generate_answer on a miss and populate the cache,
    return usedChunks (+ retrievalDebug under DEBUG). Failures still
    release the reserved quota slot and surface as 503.
  - AskAIView._streaming_response(): opt-in "stream": true returns a
    text/event-stream of chunk / done / error events; same retrieval,
    phase-3 persist, idempotency and quota accounting as the JSON path,
    and it consults the answer cache first. Non-stream JSON contract is
    untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:30:33 +03:30
ff47c8d4ba register rag app, RAG provider settings, autoload .env
settings.py
  - load .env via python-dotenv (override=False) so local runs and the RAG
    keys work without `export`; production/docker env still wins.
  - add 'rag' to INSTALLED_APPS.
  - new RAG / AI provider block: OPENAI_API_KEY / OPENAI_BASE_URL (GapGPT)
    / OPENAI_CHAT_MODEL / OPENAI_VISION_MODEL / OPENAI_EMBEDDING_MODEL /
    OPENAI_TIMEOUT_SECONDS, QDRANT_URL / QDRANT_API_KEY,
    RAG_COLLECTION_NAME_TEMPLATE / RAG_GRADE_TITLE_OVERRIDES /
    RAG_RETRIEVAL_TOP_K / RAG_FORCE_MOCK / RAG_ANSWER_CACHE_TTL.
  - add 'rag_ingest' to DEFAULT_THROTTLE_RATES.
  - under `manage.py test`: force RAG_FORCE_MOCK on and RAG_ANSWER_CACHE_TTL
    to 0 so no test hits OpenAI/Qdrant or a warm cache.
urls.py
  - mount rag.urls under /api/.
requirements.txt
  - python-dotenv; openai, qdrant-client; pymupdf / pdf2image / pillow
    (optional, only for PDF ingestion).
.env.example
  - document the OPENAI_* / QDRANT_* / RAG_* keys, the discovered Qdrant
    collection names, and the parked OPENAI_API_KEY_OPENAI_DIRECT slot
    (no provider-switch logic by design).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:30:12 +03:30
ac93e17305 new app — cleaned-up RAG pipeline (Qdrant retrieval + GPT), ported from Rag_backend
Self-contained app; wired into settings/urls in the next commit. Data
stays in the parallel team's live Qdrant — nothing is copied into our DB.

apps.py
  - RagConfig.
models.py
  - LessonChunk: DB-of-record for one summarized lesson chunk
    (original_text + summary + embedding_vector) mirrored as a single
    Qdrant point; loose lesson_id/grade_id ints, no cross-app FK.
    LessonChunkImage for future multimodal lessons.
migrations/0001_initial.py
  - initial tables for the two models.
services/clients.py
  - lazy, lru_cache'd OpenAI + Qdrant clients (Rag_backend builds them at
    import time and crashes without keys). RagNotConfigured raised when a
    key/lib is missing; Qdrant client uses check_compatibility=False for
    the 1.19-client / 1.17-server minor version gap.
services/config.py
  - rag_is_configured(), collection-name template, grade-title resolution
    (RAG_GRADE_TITLE_OVERRIDES -> content.Grade.title), retrieval top-k.
services/known_collections.py
  - exact live Qdrant catalog: 6 collection names, math chapter-number ->
    exact "فصل N: ..." string tables per (subject, grade), and the
    علوم/اجتماعی lesson-number -> title maps. filter_key_for_subject()
    picks `chapter` for math, `lesson_number` otherwise;
    resolve_math_chapter() maps a plain chapter number (Persian or Latin
    digits) to the stored string.
services/embedding.py
  - embed_text() pinned to OPENAI_EMBEDDING_MODEL (text-embedding-3-small,
    1536d) so vectors match the other team's existing collections.
services/chunking.py
  - split_text_into_chunks(), summarize_chunk() (gpt-4o-mini, JSON out);
    plain exceptions instead of ninja HttpError.
services/qdrant_store.py
  - ensure_collection(), upsert_points(), search(): the lesson filter
    matches the value both as str and native, since Qdrant stores
    lesson_number as int and chapter as string.
services/retrieval.py
  - resolve_target() -> (collection, filter_key, filter_value), reused by
    the cache key and debug block.
  - retrieve_context() -> (chunks, meta): embed -> Qdrant search scoped to
    one lesson/chapter -> top-k. Math payloads (no `summary`) fall back to
    generalSolution/problem/solution; a 404 collection returns empty.
services/generation.py
  - generate_answer() and stream_answer() (token iterator for SSE);
    context-grounded Persian prompt that explains from retrieved text
    rather than refusing.
services/pdf.py
  - textbook PDF -> page images -> gpt-4o vision problem-type extraction
    (adapted from Rag_backend mathapp); returns dicts, no on-disk JSON.
    pymupdf/pdf2image optional — missing libs raise RagNotConfigured.
services/ingest.py
  - ingest_text() / ingest_pdf(): chunk -> summarize -> embed -> persist
    LessonChunk + upsert Qdrant, in one transaction.
services/answer_cache.py
  - short-TTL cache of the final answer keyed by
    sha1(collection | filter_value | normalized-question).
services/tutor.py
  - thin answer_question() (retrieve -> generate) for direct callers.
serializers.py, urls.py, views.py
  - POST /api/rag/ingest/text, POST /api/rag/ingest/pdf (HasInternalServiceKey,
    throttle scope rag_ingest), GET /api/rag/chunks review list.
    RagProviderUnavailable -> clean 503.
admin.py
  - LessonChunk admin with an inline for its images.
management/commands/rag_catalog.py
  - read-only live Qdrant listing (--values, --json); never writes.
tests.py
  - mocked-client tests: ask-with-retrieval, ingest, SSE stream, answer
    cache, and rag_is_configured() gating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:29:54 +03:30