# معماری
تصمیمهایی که از خواندنِ کد واضح نیستند و دلیلشان.
## احرازِ هویت، سیستمِ خودمان است
`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).
**ورود فقط با شمارهی موبایل و کدِ پیامکی است.** ورودِ مهمان وجود ندارد؛ شمارهی تازه حسابِ تازه میسازد.
**نامِ نمایشی** در جدولِ رتبهبندی به بچههای دیگر نشان داده میشود؛ شماره، لینک
و آیدی هنگامِ ذخیره از آن حذف میشود (`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ِ عمومیِ خودتان باشد.
`X-Forwarded-For` فقط وقتی پذیرفته میشود که درخواست واقعاً از ArvanCloud آمده باشد
(`config.middleware.TrustedProxyMiddleware`، فهرستِ `TRUSTED_PROXY_NETWORKS`؛ آدرسِ TCP را nginx در `X-Real-IP`
میگذارد). وگرنه کسی که مستقیم به IPِ سرور بزند و در هر درخواست XFFِ تازه بفرستد، از همهی سقفهای IP-محور رد
میشود. لایهی دوم در nginx است: هر درخواستی که از آروان نیامده ۴۰۳ میگیرد (`ops/nginx/zareian-arvan-only.conf`).
وقتی آروان رنجِ IPِ تازه بگیرد، هر دو فهرست با هم تازه میشوند (`docs/operations.md` «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`. بیکلید و با `DEBUG=0` پاسخ ۵۰۳ است، نه جوابِ ساختگی
(`StubNeverReachesProductionTests`)؛ مسیرِ نمونه فقط با `DEBUG` یا `RAG_FORCE_MOCK` در دسترس است.
- `"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-v12-age-tone` است (`rag/services/generation.py`، `PROMPT_VERSION`). جدولِ زیر قاعدههایی است که
از `tutor-v3` به بعد در آن ماندهاند؛ فقطریاضیبودن در `docs/ai-cost-and-guardrails.md` و لحن بر اساسِ سن (۱۸+ لحنِ
بزرگسال در هر پایه، کشِ جواب با لحن کلید میخورد) در `tone_for`.
| چه | چرا |
|---|---|
| **متنِ ساده، بدونِ 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` فقط یعنی «کلید تنظیم شده».
### شکل در جواب
مدل 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` برای همان پایهای است که نقشهاش نشان داده
میشود: درسهای سوالدارِ تمامشده بهاضافهی درسهای جزیرههای تمامشده، تقسیم
بر درسهای سوالدارِ آن پایه.
**آزمونِ هر جزیره ثابت است:** مبنای درهمریزی فقط `nodeId:userId` است، پس دانشآموز هر بار همان سوالها را
میبیند (تصمیمِ مالک؛ `NodeQuizStabilityTests`).
**پایان فقط برای جزیرهی باز و آزمونِ جوابداده:** `quiz/complete` و `lessons/{id}/complete` جزیرهی قفل را ۴۰۳
`node_locked` میدهند (همان قاعدهی `/api/home`، `content.views.node_is_unlocked`) و کاربری که همهی سوالها را
جواب نداده ۴۰۹ `answers_missing`. تعدادِ لازم `max(totalQuestions ِ کلاینت، min(4، سوالهای جزیره))` است تا کلاینت
نتواند کمش کند. جزیره یا درسِ بیسوال، اگر باز باشد، بیستاره و بیامتیاز رد میشود تا زنجیره را نبندد.
## کتابها و دیوارِ پرداخت
هر فصل (`Unit`) به یک کتاب (`Subject`: ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) تعلق دارد. `/api/home?subject=`
نقشهی همان کتاب را میدهد و کتابِ بیربط به پایه ۴۰۰ است؛ `subjects` در پاسخ فقط کتابهای همان پایه است.
**فصلِ اولِ هر کتاب رایگان است** (`content.views.unit_is_free`). بیرون از آن، بیاشتراکِ فعال، سوالهای درس، آزمونِ
جزیره، ثبتِ جواب و پایانِ درس/جزیره ۴۰۲ `subscription_required` میدهند و `/api/home` برای هر فصل
`requiresSubscription` دارد (`content/tests_paywall.py`).
## متنِ خروجی: ساده و بیاعراب
تصمیمِ شرکت: هیچ اعراب و «ی»ِ اضافهی نوشتهشده («پایهی سوم») به کاربر نمیرسد؛ تنوین میماند. این **در خروجی**
انجام میشود، نه در داده: `config.renderers.PlainPersianJSONRenderer` روی هر پاسخِ JSON و `rag.services.mathtext.to_plain`
روی پاسخِ دستیار (استریم هم). دیتابیس، seed و ادمین متنِ اصلی را نگه میدارند، چون بازنویسیِ عنوانها اتصالِ سوال به
درس (که با مسیرِ عنوان است) و کلیدهای `SeedRecord` را میشکست. تست: `config/tests_plain_persian.py`.
هیچ LaTeX یا markdownی هم به دانشآموز نمیرسد: `content/plain_math.py` روی seed و ورودِ سوال، `to_plain` روی دستیار،
و `plainMath.ts` در فرانت بهعنوانِ آخرین تور؛ این دو پیادهسازی باید همرفتار بمانند.
## دستیار و سوالِ تمرینی
اگر اپ در `POST /api/ai/ask` فیلدِ `questionId` را بفرستد، دستیار صورتِ همان
سوال، گزینهها، جوابِ درست و **آخرین جوابِ خودِ دانشآموز** را میبیند
(`rag.services.generation.question_focus`) و اگر غلط بوده، اول میگوید اشتباه در
کدام مرحله بوده. این همان «کمک در جایی که بچهها گیر میکنند» است. این
جوابها کش نمیشوند. `userQuestion` حداکثر ۲۰۰۰ نویسه است.
## دستیار بدونِ درس
صفحهی «از پی بپرس» از نوارِ پایین باز میشود و `lessonId` ندارد. در این حالت
`/api/ai/ask` کتاب را از پایهی ذخیرهشدهی خودِ کاربر درمیآورد
(`rag.services.lesson_context.resolve_from_grade`) و در کلِ همان کتاب جستجو
میکند؛ قبلاً retrieval رد میشد و جواب عمومی بود. `lessonId` اگر بیاید همچنان
دقیقتر است، چون فصل را هم معلوم میکند.
## استریک و جدولِ رتبهبندی، زنده از فعالیت
- **استریک** (`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، بیجواب دادن، امتیاز و جدولِ رتبهبندی را
پر میکرد. اپِ فعلی هر سوال را پیش از پایان ثبت میکند، پس برایش فرقی نکرد.
## دیتابیس
روی سرور **PostgreSQL 14** است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost) و بی `DB_NAME` در `.env` برنامه
SQLite را برمیدارد (توسعهی محلی و تستها). کلِ تستها روی هر دو سبزند. قفلهای ردیفی (`select_for_update`) در
پرداخت، سقفِ کدِ تخفیف، قلب و سهمیهی دستیار روی Postgres قفلِ واقعیاند؛ روی SQLite همان تراکنشها با
`transaction_mode: IMMEDIATE` سریالی میشوند، چون SQLite `FOR UPDATE` را بیصدا نادیده میگیرد.
**اتصالها از poolِ هر worker میآیند** (`OPTIONS.pool`، `DB_POOL_MAX_SIZE`): Postgresِ سرور `max_connections=60`
دارد و الگوی «یک اتصال برای هر نخ» (۴ worker × ۳۲ نخ) آن را پر میکرد. poolِ پر ۵۰۳ با `Retry-After` میدهد، نه ۵۰۰
(`config/db.py`)، و `/api/health` → `dbPool` بودجهی `workers × pool + 6 ≤ max_connections` را میسنجد. فراخوانیهای
شبکهای (AI، زرینپال، پیامک) بیرون از `atomic()` هستند و دستیار و پیامک پیش از انتظارِ شبکه اتصال را پس میدهند
(`release_db_connection`).
تستها روی فایلِ `.test-db.sqlite3` اجرا میشوند، نه دیتابیسِ حافظهای: آن یکی
shared-cache است و قفلش جدولبهجدول و بیانتظار است، رفتاری که روی فایلِ واقعی
پیش نمیآید.
**idِ هر پایه همان شمارهی پایه است** (سوم=۳، هشتم=۸، دوازدهم=۱۲) و در فیلدِ
`Grade.number` هم هست. پایهی تازه id را از شمارهاش میگیرد، نه از شمارندهی
دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده میشود. پایهی
بیشماره ([DEMO]/[SEED]) idِ بالای ۱۰۰ میگیرد. همهجا — API، ادمین، فیلترِ
سوالها — پایهها بهترتیبِ شمارهاند.
⚠️ **`.iterator()` را دورِ حلقهای که مینویسد یا I/O بیرونی میزند نگذارید.**
کرسرِ خواندن در تمامِ طولِ حلقه باز میماند و قفل و اتصال را تا پایانِ حلقه نگه میدارد؛ روی SQLite هیچ retryای
نجاتش نمیدهد. این یک بارْ ماهها بهشکلِ یک تستِ «flaky» ظاهر شد؛ باگِ واقعی بود، نه تصادفِ زمانبندی. بهجایش
نتیجه را با `list(...)` مادی کنید.
## لاگِ ساختاریافته
`config.middleware.APIRequestLoggingMiddleware` (آخرین middleware، نزدیکترین
به ویو) هر درخواستِ `/api/` را بهصورتِ یک شیءِ JSON در هر خط لاگ میکند:
متد، مسیر، وضعیت، مدت، کاربر — **بدونِ بدنه، هدر یا سکرت**
(`config/log_formatters.py`).