accounts.0008 drops the columns and refuses to run while guest rows exist. Docs describe the current state; tests that only proved guest behaviour are gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
320 lines
30 KiB
Markdown
320 lines
30 KiB
Markdown
# معماری
|
||
|
||
تصمیمهایی که از خواندنِ کد واضح نیستند و دلیلشان.
|
||
|
||
## احرازِ هویت، سیستمِ خودمان است
|
||
|
||
`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ِ عمومیِ خودتان باشد.
|
||
|
||
⚠️ سرور روی پورتِ ۸۰ مستقیم هم (بدونِ آروان) جواب میدهد. کسی که 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 نمینویسد. در پایانِ جوابِ شکلدار یک بلوکِ کوتاهِ `<diagram>{JSON}</diagram>` میگذارد
|
||
که فقط **توصیف** است («مثلثی با 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`).
|