# معماری تصمیم‌هایی که از خواندنِ کد واضح نیستند و دلیلشان. ## احرازِ هویت، سیستمِ خودمان است `accounts.User` عمداً `AUTH_USER_MODEL` **نیست** — یک مدلِ بیزنسیِ مستقل است. ورود با توکنِ bearerِ امضاشده و stateless انجام می‌شود (`django.core.signing`، بدونِ کتابخانه‌ی JWT)، که در `accounts/authentication.py` صادر و تأیید می‌شود و پشتوانه‌اش مدلِ `accounts.Session` است. هدرِ نبود یا نامعتبر یعنی **درخواستِ ناشناس، نه خطا**. هر ویو با `permission_classes` خودش تصمیم می‌گیرد که آیا ورود لازم است — یعنی یک اندپوینتِ عمومی و یک اندپوینتِ خصوصی از یک مسیرِ احراز هویت رد می‌شوند و اختلافشان فقط در یک جاست. `DJANGO_SECRET_KEY` همین توکن‌ها را امضا می‌کند. عوض شدنش یعنی **باطل شدنِ همه‌ی نشست‌های فعال**. **توکنِ access فقط `user_id`ِ امضاشده است و به `Session` وصل نیست.** خروج (`POST /api/auth/logout`) refreshِ همان دستگاه را باطل می‌کند و `User.tokens_valid_after` را می‌گذارد؛ هر توکنِ access که پیش از آن لحظه صادر شده رد می‌شود (دقتِ ثانیه‌ای). دستگاه‌های دیگر با refreshِ خودشان بی‌صدا توکنِ تازه می‌گیرند. پیش از ۲۰۲۶-۰۹-۲۵ خروج وجود نداشت. **کدِ ورود ۶ رقمی است و هر کد بعد از ۵ حدسِ غلط باطل می‌شود** (`OTPCode.failed_attempts`، شمارش در دیتابیس، نه در کشِ per-worker). **مهمانی که با شماره وارد می‌شود همان کاربر می‌ماند.** اگر درخواستِ ورود توکنِ مهمان را همراه دارد و آن شماره هنوز حسابی ندارد، به همان ردیفِ مهمان شماره داده می‌شود؛ امتیاز، استریک، جزیره‌ها و گفتگوها سرِ جایشان می‌مانند. شماره‌ای که از قبل حساب دارد ادغام نمی‌شود (حسابِ موجود باز می‌شود و مهمان دست‌نخورده می‌ماند)، و `TEST_LOGIN_PHONES` هرگز مهمانی را جذب نمی‌کنند تا حساب‌های دمو عوض نشوند. **نامِ نمایشی** در جدولِ رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود؛ شماره، لینک و آیدی هنگامِ ذخیره از آن حذف می‌شود (`accounts.serializers.clean_display_name`). پنلِ `/admin/` برای هر IP در ۱۵ دقیقه حداکثر `ADMIN_LOGIN_MAX_ATTEMPTS` (۱۰) تلاشِ ورود می‌پذیرد (`config.middleware.AdminLoginThrottleMiddleware`). ## اپ‌ها با قرارداد جدا شده‌اند، نه با ForeignKey هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد. ارجاعِ بینِ اپ‌ها با `user_id` یا `object_id` + `label` انجام می‌شود، نه با FK واقعی. دلیلش این است که هر اپ بعداً قابلِ جدا شدن بماند. **FK بینِ اپ‌ها اضافه نکنید** — الگوی ارجاعِ شل را دنبال کنید. ## محدودیتِ نرخ در یک جا تعریف می‌شود هر اندپوینتِ حساس یک `throttle_scope` دارد و نرخش در `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` داخلِ `config/settings.py` است. اندپوینتِ جدیدِ حساس یعنی **هر دو**: یک `throttle_scope` روی ویو و یک ردیفِ متناظر در تنظیمات. کاربرانِ مهمان با IP محدود می‌شوند. `NUM_PROXIES` تعیین می‌کند چند hop از `X-Forwarded-For` قابلِ اعتماد است و **باید با تعدادِ واقعیِ پروکسی‌ها یکی باشد**، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن می‌شود. روی پروداکشن این عدد **۲** است (پیش‌فرضِ کد از ۲۰۲۶-۰۹-۲۴): ArvanCloud جلوی سرور است و بعد nginxِ خودِ ماشین. تا آن روز ۱ بود و جنگو IPِ آروان را IPِ کاربر می‌دید؛ یعنی همه‌ی مهمان‌ها یک سهمِ مشترک داشتند. `clientIp` در `/api/health` نشان می‌دهد سرور صدازننده را با چه IPای می‌شناسد — باید IPِ عمومیِ خودتان باشد. ⚠️ سرور روی پورتِ ۸۰ مستقیم هم (بدونِ آروان) جواب می‌دهد. کسی که IPِ سرور را بداند می‌تواند از آن راه XFF جعل کند و محدودیتِ IP را دور بزند. ورودِ OTP جدا به ازای هر شماره هم محدود است، پس این راه به حدسِ کد نمی‌رسد. بستنِ کاملش (پورتِ ۸۰ فقط برای رنج‌های آروان، یا `set_real_ip_from` در nginx) کارِ ادمینِ سرور است. ## پرداخت: هرگز مستقیم به درگاه `create_order` هیچ‌وقت زرین‌پال را صدا نمی‌زند. `paymentUrl` به اندپوینتِ خودمان اشاره می‌کند (`GET /api/payment/orders/{id}/pay`)، که در اولین بازدید PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره می‌کند و به StartPay می‌فرستد. **این کار ساختِ سفارش را به در دسترس بودنِ زرین‌پال وابسته نمی‌کند.** بازگشتِ کاربر به `GET /api/payment/zarinpal/callback` می‌رسد، که **همیشه** سرور-به-سرور با `payment/zarinpal.py::verify_payment` دوباره تأیید می‌کند. یک `Status=OK` جعلی در query string به‌تنهایی هیچ کاری نمی‌کند. هم `payment/zarinpal.py` و هم `payment/bazaar.py` وقتی اعتبارنامه ندارند **fail-closed** می‌شوند (`503` یا نامعتبر)، نه موفقیتِ ساختگی. همین قاعده برای پیامک هم برقرار است. قاعده‌هایی که بازبینیِ ۲۰۲۶-۰۹-۲۵ اضافه کرد (هرکدام با تست در `payment/tests_audit_fixes.py`): | قاعده | چون پیش‌تر… | |---|---| | **پلن روی سفارش است** (`Order.plan_id`) و فعال‌سازی از همان خوانده می‌شود | پلن روی Subscription بود؛ شروعِ خریدِ سالانه و پرداختِ سفارشِ ماهانه، اشتراکِ سالانه می‌داد | | **باز کردنِ صفحه‌ی خرید اشتراکِ فعال را دست نمی‌زند** | وضعیت همان لحظه `pending` می‌شد و کاربرِ پولی مزیتش را از دست می‌داد | | **تمدید از تاریخِ انقضای فعلی** حساب می‌شود | تمدیدِ زودهنگام روزهای باقی‌مانده را پاک می‌کرد | | `subscription/status` اشتراکِ منقضی را `active: false` می‌دهد | فقط ستونِ status دیده می‌شد | | **یک کدِ تخفیف برای هر سفارش، و فقط پیش از رفتن به درگاه** | کدها جمع می‌شدند؛ و تخفیف بعد از گرفتنِ authority یعنی پرداختِ مبلغِ قبلی و ردِ verify با مبلغِ جدید | | **کدِ ۱۰۰٪ خرید را همان‌جا تمام می‌کند** (`gateway='discount'`) | سفارشِ صفرتومانی را هیچ درگاهی نمی‌پذیرد | | **پرداختِ تأییدنشده دوباره سنجیده می‌شود**: هر بار که اپ `subscription/status` یا جزئیاتِ سفارش را می‌خواند (حداکثر دقیقه‌ای یک بار برای هر سفارش) و با `manage.py payment_reconcile` | اگر زرین‌پال موقعِ کال‌بک جواب نمی‌داد، پول کم شده بود و سفارش برای همیشه pending می‌ماند. فقط به سمتِ paid، هرگز failed | | `/api/payment/webhook` حذف شد | یک «این سفارش پرداخت شد»ِ عمومی بود پشتِ یک رمزِ مشترک، که زرین‌پال اصلاً صدایش نمی‌زد | ## برنامه‌ی معرفی (affiliate) کاربرِ واردشده با شماره کدِ معرف می‌گیرد (`GET /api/affiliate/me`) و کاربرِ تازه کدِ دیگری را ثبت می‌کند (`POST /api/affiliate/track`). **پورسانت فقط روی اولین پرداختِ واقعیِ کاربرِ معرفی‌شده پس از ثبتِ کد** حساب می‌شود. تازه بودنِ کاربر سنجیده نمی‌شود: مشتریِ قدیمی هم می‌تواند بعداً کد ثبت کند و تمدیدش پورسانت بسازد (تصمیمِ محصولیِ باز، بازبینیِ ۲۰۲۶-۰۹-۲۵) (`affiliate.services.record_paid_order`، از داخلِ تأییدِ پرداخت): `commission_amount = مبلغِ پرداختی × commission_rate` و Conversion `paid` می‌شود. مهمان نه کد می‌گیرد نه معرفی‌شده حساب می‌شود (۴۰۳). تسویه‌ی پورسانت با معرف هنوز دستی و از پنلِ ادمین است. ## دستیارِ هوشِ مصنوعی (RAG) `POST /api/ai/ask` پاسخ را از محتوای واقعیِ کتاب می‌سازد: embedding سوال → جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تکه‌های استفاده‌شده در `usedChunks` برمی‌گردند، پس پاسخ قابلِ ردیابی به منبع است. - نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY`. بدونِ کلید، همان پاسخِ stubِ قبلی برمی‌گردد (سازگاریِ عقب‌رو). - `"stream": true` پاسخ را به‌صورتِ SSE توکن‌به‌توکن می‌فرستد؛ قراردادِ JSON بدونِ این فلگ دست‌نخورده است. - `python manage.py rag_catalog` فهرستِ زنده‌ی collectionهای Qdrant را نشان می‌دهد (فقط‌خواندنی). - افزودنِ محتوای خودمان: `POST /api/rag/ingest/text` و `/api/rag/ingest/pdf` (داخلی، با هدرِ `X-Internal-Key`). کیفیتِ محتوایی که این لایه می‌خواند در [`content-authoring.md`](content-authoring.md) توضیح داده شده. ### شکلِ پاسخ (پرامپتِ `tutor-v3-stepwise-plain`، ۲۰۲۶-۰۹-۲۵) | چه | چرا | |---|---| | **متنِ ساده، بدونِ LaTeX/markdown**؛ `rag/services/mathtext.py` هرچه از دستِ مدل در برود را به یونیکد برمی‌گرداند (`x²`، `√3/2`، `≤`) | اپ پاسخ را خام نشان می‌دهد. همه‌ی جواب‌های ذخیره‌شده‌ی پایه‌های ۸ تا ۱۲ پر از `\frac` و `###` بودند | | **حلِ کامل، مرحله‌به‌مرحله، هر مرحله یک خط، وارسی پیش از «✅ جواب»، و یک تمرینِ مشابه** | تصمیمِ صاحبِ پروژه. وارسی به این خاطر است که جوابی مشتقِ x³−6x²+9x را 3x²−12x نوشته بود | | **لحنِ سه‌سطحی** (دبستان / متوسطه‌ی اول / دبیرستان) | «پایه‌ی سوم» در پرامپت به‌تنهایی لحن را عوض نمی‌کرد | | **نمادِ اعداد تابعِ پایه**: دبستان رقمِ فارسی و «/» مثلِ کتاب برای ممیز، کسر با کلمه («۳ بر ۴»)؛ از هفتم به بعد ممیز با نقطه و «/» فقط برای کسر | در کتاب‌های ایران «۲/۵» یعنی دو و نیم، نه کسر | | **حافظه‌ی گفتگو**: ۶ پیامِ آخر به مدل می‌رود و سوالِ قبلیِ کاربر به جستجو اضافه می‌شود | «نفهمیدم» برای مدل هیچ مرجعی نداشت | | **کش فقط برای پیامِ اولِ گفتگو**، و کلیدش نسخه‌ی پرامپت را دارد | «نفهمیدم»ِ یک نفر جوابِ کش‌شده‌ی گفتگوی دیگری را می‌گرفت | | **راه‌حلی که `rag_verify_solutions` ردش کرده (`disagrees`) به مدل نمی‌رسد**؛ فقط روش و صورتِ مسئله | حدودِ ۱۳٪ تکه‌های کتاب‌های دهم تا دوازدهم این‌طورند و دستیار غلطِ کتاب را تکرار می‌کرد | **انتخابِ مدل:** `OPENAI_CHAT_MODEL` برای همه، و `OPENAI_CHAT_MODEL_UPPER` (اختیاری) فقط برای دهم تا دوازدهم. مدل‌های reasoning (o-series، gpt-5) بدونِ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند. انتخاب با `python manage.py rag_model_benchmark` سنجیده می‌شود: مسئله‌های تأییدشده‌ی کتاب، از مسیرِ واقعیِ retrieval + پرامپت، با داورِ ثابت و هزینه‌ی واقعی از `/dashboard/billing/usage`. `LessonChunk` فقط در دیتابیسِ محلی است و GapGPT فقط از ایران جواب می‌دهد، پس نمونه با `--export-sample` محلی ساخته و روی سرور با `--sample-file` اجرا می‌شود. `--resume` مدل‌های تمام‌شده را دوباره اجرا نمی‌کند. هزینه‌ی هر پاسخ از `usage.cost`ِ خودِ GapGPT خوانده می‌شود، و `--context-cache` بخش‌های بازیابی‌شده را ذخیره می‌کند تا چند اجرا (چند مدل یا چند نسخه‌ی پرامپت) دقیقاً یک context ببینند. **انتخابِ فعلی: `gpt-4.1-mini` (۲۰۲۶-۰۹-۲۶).** دورِ اول ۱۴ مدل روی ۳۰ مسئله: پنج مدل بی‌خطا بودند و 4.1-mini ارزان‌ترینشان بود. دورِ دوم 4o-mini در برابرِ 4.1-mini روی ۱۰۲ مسئله با سه نسخه‌ی پرامپت: ۹۵–۹۶ در برابرِ ۸۵–۹۰. نوسانِ 4o-mini با پرامپتِ یکسان تا ۵ مسئله بود، پس این فاصله واقعی است. خطاهای 4o-mini بیشتر حسابِ ساده بود (۸۴۵۱ − ۷۲۳۴ = ۱۳۱۷). هر پاسخ ۰٫۱۲ سنت است، ۲٫۹ برابرِ 4o-mini. داورِ خودکار (gpt-4.1) حدودِ ۷٪ اشتباه داوری کرد (مثلاً 3¹⁴ و (−3)¹⁴ را متفاوت دانست)، پس نتیجه‌ها دستی تصحیح شد. **«/» در دبستان (پرامپتِ `tutor-v5-slash-in-question`).** در کتاب‌های دبستان «/» هم ممیز است هم کسر، چون کسرِ دوطبقه در استخراج صاف می‌شود. جمله‌ی «در کتاب / ممیز است، نه کسر» که برای دبیرستان نوشته شده بود به همه می‌رفت، و 4.1-mini با آن ۱/۷ + ۱/۸ را ۳/۵ حساب می‌کرد. حالا برای دبستان آن جمله عوض شده و قاعده‌ی تشخیص (صفر پیش از «/» یا واحد = ممیز؛ کلمه‌ی «کسر» یا بی‌نشانه = کسر) کنارِ خودِ سوال می‌آید. همین قاعده در انتهای پرامپتِ سیستم اثری نداشت. نتیجه: مسئله‌های «/»ِ 4.1-mini از ۱۱ به ۱۴ از ۱۷. سه‌تا هنوز غلط است. در سه اجرای بعدیِ v5 عدد بینِ ۱۱ و ۱۳ نوسان کرد، پس اثرِ واقعی کمتر از یک اجراست. **کسر در جوابِ دبستان هنوز گاهی «۵/۶» نوشته می‌شود، نه «۵ بر ۶»** (در ۳ تا ۵ از ۷ جوابِ کسری «بر» آمد). کد نمی‌تواند a/b را تبدیل کند، چون ممکن است ممیز باشد. آزمایشِ v6 (۲۰۲۶-۰۹-۲۶) یک خطِ «کسر را در پاسخ با بر بنویس» را کنارِ قاعده‌ی خواندن گذاشت: نمادگذاری کمی بهتر شد (۴–۵ از ۷) ولی درستی کمی بدتر (مسئله‌های «/» ۱۰–۱۱ در برابرِ ۱۱–۱۳؛ ۱/۷ + ۱/۸ در هر سه اجرا ۳/۵ شد، با v5 در هر سه درست). جوابِ غلط از نمادِ نادرست بدتر است، پس v6 منتشر نشد. راهِ بعدی احتمالاً در فرانت است (دکمه‌ی کسر در ورودی، یا نمایشِ کسرِ دوطبقه)، نه در پرامپت. **تمام شدنِ اعتبار دیده می‌شود.** هر گذارِ provider بینِ `ok` و `quota_exhausted` یک ردیف در `statuslog` است (`ai_tutor/provider_status.py`) و `/api/health` آن را در `integrations.aiProvider` و `warnings` نشان می‌دهد. پیش‌تر `openai: true` فقط یعنی «کلید تنظیم شده». ### شکل در جواب (پرامپتِ `tutor-v10-shape-anchor`، ۲۰۲۶-۰۹-۲۹) مدل HTML یا SVG نمی‌نویسد. در پایانِ جوابِ شکل‌دار یک بلوکِ کوتاهِ `{JSON}` می‌گذارد که فقط **توصیف** است («مثلثی با AB = 6، AC = 8 و زاویه‌ی قائمه در A»). فرانت (`src/diagram/`) از روی آن SVG می‌سازد و هر عددی را که قابلِ حساب است خودش حساب می‌کند: رأس‌های مثلث با قانونِ سینوس و کسینوس، ریشه و رأسِ نمودار با نصف‌کردنِ بازه، مماس با مشتقِ عددی، رأس‌های چندضلعیِ منتظم. | لایه | کجا | کار | |---|---|---| | تشخیص | `diagram.hint_for` / `follow_up_hint_for` / `asked_hint_for` | کنارِ سوال فقط بخشِ مربوطِ کاتالوگ می‌رود: کلمه‌ی موضوعی (یا نامِ درس)، سوالِ شکل‌دارِ قبلی، یا درخواستِ صریحِ «شکلش رو بکش» (کلِ کاتالوگ). سوالِ حسابی هیچ | | جدا کردن | `diagram.StreamSplitter` / `extract` | بلوک پیش از `mathtext` از متن جدا می‌شود؛ JSON هیچ‌وقت به حباب نمی‌رسد | | تعمیر | `diagram._repair` / `_loads` / `_normalize` | لغزش‌های واقعیِ مدل: رقمِ فارسی، `2*pi`، `-2 * 3.14`، گیومه‌ی جاافتاده، کلیدِ تکراری، `"kind":"trapezoid"` | | وارسی | `diagram.validate` | فهرستِ سفیدِ نوع و کلید و رنگ، عددِ محدود، برچسبِ کوتاه؛ تکه‌ی خراب حذف، شکلِ بی‌محتوا `None` | | نگهبانِ معنا | `diagram.fits` | ساعتِ دیواری فقط برای سوالی که واقعاً درباره‌ی خواندنِ ساعت است | | ذخیره | `Message.diagram` | در `done`ِ SSE و تاریخچه می‌آید | سنجش (۶۴ مسئله‌ی تصادفیِ کتاب، ۸ از هر پایه، وارسیِ چشمیِ تک‌تک): ۴۰ درست، ۱۲ قابلِ قبول، ۷ غلط، ۵ بی‌شکل؛ برآوردِ ۹۵٪ برای شکلِ درست ۵۰–۷۳٪ و برای شکلِ غلط ۵–۲۱٪. هزینه‌ی اضافه‌ی شکل با نرخِ رسمی ۰٫۰۰۰۳ تا ۰٫۰۰۰۸ دلار در هر جواب؛ کارِ سرور حدودِ ۰٫۴ میلی‌ثانیه. آزموده نشده: مدل‌های دیگر. برای تستِ مسیر بی‌خرج، `tools/fake_openai.py` جای GapGPT می‌نشیند. ## ساختِ آزمون اپ در سطحِ **نود** بازی می‌شود (جزیره روی نقشه)، ولی سوال‌ها به **درس** وصلند — و درس اینجا ریزترین دانه است: یک نوعِ مسئله از کتاب، نه یک جلسه‌ی کلاسی. `GET /api/nodes/{id}/quiz` آزمون را از درس‌های یک نود می‌سازد، **حداکثر یک سوال از هر درس**. ترتیب با `nodeId:userId` seed می‌شود، پس رفرش همان آزمون را برمی‌گرداند. `questionCount` جدا از `requested` گزارش می‌شود تا یک نودِ کم‌سوال شبیهِ خطا به نظر نرسد. **پایانِ آزمون، پایانِ جزیره است.** `POST /api/nodes/{id}/quiz/complete` با `{correctCount, totalQuestions}` جزیره را تمام می‌کند و جزیره‌ی بعدی روی نقشه باز می‌شود — با هر نمره‌ای؛ نمره فقط ستاره و امتیاز را تعیین می‌کند (تصمیمِ صاحبِ پروژه، ۲۰۲۶-۰۹-۲۴: ساده نگهش داریم). پاسخ `nextNodeId` را هم می‌دهد. امتیاز و سکه فقط بارِ اول. ردیف‌ها در `UserNodeCompletion`اند؛ `_node_status` نودی را که آزمونش تمام شده `completed` می‌بیند، و مسیرِ قدیمیِ «همه‌ی درس‌ها تک‌تک» هم هنوز کار می‌کند. **جزیره با درس‌ها هم تمام می‌شود، ولی فقط درس‌های سوال‌دار شمرده می‌شوند** (۲۰۲۶-۰۹-۲۵). ۵۸۵ درس هنوز سوال ندارند و اپ برایشان دکمه‌ی شروع ندارد؛ با شرطِ «همه‌ی درس‌ها» ۱۸۱ جزیره از ۲۶۹ هرگز تمام نمی‌شد. هر نود در `/api/home` `nextLessonId` دارد (اولین درسِ سوال‌دارِ تمام‌نشده) و **عضوِ اولِ `lessonIds` همیشه همان است** — فرانت با کلیک روی جزیره `lessonIds[0]` را باز می‌کند و پیش‌تر هر بار درسِ اول را می‌گرفت. جزیره‌ای که یک بار تمام شد (`NodeProgress` یا آزمون) تمام می‌ماند، حتی اگر بعداً درسی سوالِ تازه بگیرد؛ سوالِ تازه کشِ نقشه را باطل می‌کند. `progressPercent` در `/api/home` برای همان پایه‌ای است که نقشه‌اش نشان داده می‌شود: درس‌های سوال‌دارِ تمام‌شده به‌اضافه‌ی درس‌های جزیره‌های تمام‌شده، تقسیم بر درس‌های سوال‌دارِ آن پایه. (قبلاً مخرج همه‌ی ۲٬۶۱۷ درسِ همه‌ی پایه‌ها بود.) ## دستیار و سوالِ تمرینی اگر اپ در `POST /api/ai/ask` فیلدِ `questionId` را بفرستد، دستیار صورتِ همان سوال، گزینه‌ها، جوابِ درست و **آخرین جوابِ خودِ دانش‌آموز** را می‌بیند (`rag.services.generation.question_focus`) و اگر غلط بوده، اول می‌گوید اشتباه در کدام مرحله بوده. این همان «کمک در جایی که بچه‌ها گیر می‌کنند» است. این جواب‌ها کش نمی‌شوند. `userQuestion` حداکثر ۲۰۰۰ نویسه است. ## دستیار بدونِ درس صفحه‌ی «از پی بپرس» از نوارِ پایین باز می‌شود و `lessonId` ندارد. در این حالت `/api/ai/ask` کتاب را از پایه‌ی ذخیره‌شده‌ی خودِ کاربر درمی‌آورد (`rag.services.lesson_context.resolve_from_grade`) و در کلِ همان کتاب جستجو می‌کند؛ قبلاً retrieval رد می‌شد و جواب عمومی بود. `lessonId` اگر بیاید همچنان دقیق‌تر است، چون فصل را هم معلوم می‌کند. ## استریک و جدولِ رتبه‌بندی، زنده از فعالیت تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای اپ `StreakRecord` نمی‌نوشت و `GET /api/leaderboard` فقط جدولی را می‌خواند که با اجرای دستیِ `rebuild_leaderboard` پر می‌شد؛ یعنی برای کاربرِ واقعی «روز پیاپی» همیشه صفر بود و XPاش هیچ‌وقت در جدول دیده نمی‌شد. - **استریک** (`progress/services.py`): پاسخ به سوال، پایانِ درس و پایانِ جزیره امروز را (به وقتِ تهران) روزِ فعال ثبت می‌کنند. نمایش با `effective_current_streak` است: اگر آخرین روزِ فعال نه امروز است نه دیروز، صفر نشان داده می‌شود. خطای استریک هرگز پاسخ یا تکمیل را خراب نمی‌کند. - **رتبه‌بندی** (`progress/leaderboard.py`): زنده حساب و ۶۰ ثانیه کش می‌شود. `all_time` از `UserStats.xp`، `weekly` از XPی درس‌ها و جزیره‌های ۷ روزِ اخیر. `rebuild_leaderboard` هم از همین تابع می‌خواند و فقط جدولِ ادمین را پر می‌کند. ## قلب‌ها، مثلِ دولینگو هر پاسخِ غلط یک قلب کم می‌کند (اشتراکِ فعال یا روزهای قلبِ بسته‌ی سوال = قلبِ نامحدود). **هر ۵ ساعت یک قلب برمی‌گردد** (`HEART_REFILL_INTERVAL`) و **۲۴ ساعت بعد از اولین قلبی که از حالتِ پر کم شد، همه یک‌جا پر می‌شوند** (`HEART_FULL_REFILL_AFTER`، فیلدِ `full_refill_at`). `GET /api/user/hearts` هر دو زمان را می‌دهد: `nextRefillAt` و `fullRefillAt`. تا ۲۰۲۶-۰۹-۲۴ هر قلب ۲۴ ساعت بود؛ تایمرِ قدیمیِ بلندتر از ۵ ساعت در اولین خواندن کوتاه می‌شود. **با صفر قلب، بک‌اند جواب را رد می‌کند** (۴۰۳ با `code: "no_hearts"`، `nextRefillAt` و `fullRefillAt`). `HEARTS_ENFORCED` از ۲۰۲۶-۱۰-۰۲ پیش‌فرض روشن است و فرانت برای این پاسخ صفحه‌ی «قلب‌هات تموم شد» را با شمارشِ معکوس دارد (`src/components/NoHeartsScreen.tsx`). از همان روز: - چکِ قلب داخلِ همان تراکنشی است که قلب را کم می‌کند. پیش‌تر بیرونش بود و دو جوابِ غلطِ هم‌زمان با یک قلبِ باقی‌مانده هر دو پذیرفته می‌شدند (با دو thread بازتولید شد). - پایانِ درس یا جزیره بی‌قلب و بی‌جواب دادنِ سوال‌هایش ۴۰۳ می‌گیرد؛ پیش‌تر `quiz/complete` اجازه می‌داد با قلبِ صفر جزیره‌به‌جزیره جلو رفت. کسی که آخرین قلبش را روی آخرین سوال از دست داده، همه را جواب داده و جزیره را تمام می‌کند. - قلبی که در انتظار برگشته پیش از جواب ذخیره می‌شود؛ پیش‌تر پاسخِ جوابِ درست «۰ قلب» می‌گفت و اپ دانش‌آموز را دوباره به صفحه‌ی انتظار می‌برد. تست‌ها: `content/tests_hearts_gate.py`. **امتیاز فقط برای جوابِ ثبت‌شده است.** `lessons/{id}/complete` و `nodes/{id}/quiz/complete` عددِ `correctCount`ِ کلاینت را به تعدادِ سوال‌های متمایزی که کاربر واقعاً درست جواب داده و در `UserAnswer` ثبت شده محدود می‌کنند؛ پیش‌تر صدا زدنِ مستقیمِ این دو endpoint، بی‌جواب دادن، امتیاز و جدولِ رتبه‌بندی را پر می‌کرد. اپِ فعلی هر سوال را پیش از پایان ثبت می‌کند، پس برایش فرقی نکرد. ## دیتابیس هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند). مهاجرت به Postgres انجام نشده. **`transaction_mode: IMMEDIATE`** (۲۰۲۶-۰۹-۲۵): هر `atomic()` قفلِ نوشتن را از اول می‌گیرد. SQLite از `SELECT … FOR UPDATE` پشتیبانی نمی‌کند و جنگو `select_for_update` را رویش بی‌صدا نادیده می‌گیرد؛ یعنی تا آن روز همه‌ی «قفلِ ردیف»ها در پرداخت، سقفِ کدِ تخفیف، قلب و سهمیه‌ی دستیار عملاً قفل نبودند. حالا کلِ تراکنش‌ها سریالی‌اند (درشت ولی درست). فراخوانی‌های شبکه‌ای (AI، زرین‌پال) بیرون از `atomic()` هستند و باید بمانند. بعد از Postgres این گزینه برداشته می‌شود. تست‌ها روی فایلِ `.test-db.sqlite3` اجرا می‌شوند، نه دیتابیسِ حافظه‌ای: آن یکی shared-cache است و قفلش جدول‌به‌جدول و بی‌انتظار است، رفتاری که روی فایلِ واقعی پیش نمی‌آید. **idِ هر پایه همان شماره‌ی پایه است** (سوم=۳، هشتم=۸، دوازدهم=۱۲) و در فیلدِ `Grade.number` هم هست. پایه‌ی تازه id را از شماره‌اش می‌گیرد، نه از شمارنده‌ی دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده می‌شود. پایه‌ی بی‌شماره ([DEMO]/[SEED]) idِ بالای ۱۰۰ می‌گیرد. همه‌جا — API، ادمین، فیلترِ سوال‌ها — پایه‌ها به‌ترتیبِ شماره‌اند. تا migration‌ِ `content.0008` id ترتیبِ واردشدنِ کتاب‌ها بود (نهم=۱، هشتم=۵). ⚠️ **`.iterator()` را دورِ حلقه‌ای که می‌نویسد یا I/O بیرونی می‌زند نگذارید.** کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و روی SQLite جدول را برای هر نویسنده‌ی هم‌زمان قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود و هیچ retryای نجاتش نمی‌دهد. این یک بارْ ماه‌ها به‌شکلِ یک تستِ «flaky» ظاهر شد؛ باگِ واقعی بود، نه تصادفِ زمان‌بندی. به‌جایش نتیجه را با `list(...)` مادی کنید. ## لاگِ ساختاریافته `config.middleware.APIRequestLoggingMiddleware` (آخرین middleware، نزدیک‌ترین به ویو) هر درخواستِ `/api/` را به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌کند: متد، مسیر، وضعیت، مدت، کاربر — **بدونِ بدنه، هدر یا سکرت** (`config/log_formatters.py`).