- CLAUDE.md: 1,225 → ~430 lines, current state only (the dated status log stays in git history). Server rules moved to the top: what is ours on 249/247 and what is not, non-negotiable rules, announce-before-acting, password handling, and the full "deploy it" procedure with verification. - README: formal entry page with current numbers (10 grades, 18,522 questions, 61 API operations, 973 tests, PostgreSQL/Redis). - Removed docs/frontend/ (Figma-era guide with guest login and its own api.ts; the frontend repo has the real client). Swagger's external-docs link now points at the frontend repo. - architecture/operations/security/content-authoring/loadtest/textbooks: SQLite, guest login, demo accounts, question packs and the port-80 note replaced by what is true now. - Technical handbook text updated throughout; all seven diagrams redrawn (PostgreSQL, phone-only login, subscriptions, current data model); PDF rebuilt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
343 lines
32 KiB
Markdown
343 lines
32 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ِ عمومیِ خودتان باشد.
|
||
|
||
`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 نمینویسد. در پایانِ جوابِ شکلدار یک بلوکِ کوتاهِ `<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` برای همان پایهای است که نقشهاش نشان داده
|
||
میشود: درسهای سوالدارِ تمامشده بهاضافهی درسهای جزیرههای تمامشده، تقسیم
|
||
بر درسهای سوالدارِ آن پایه.
|
||
|
||
**آزمونِ هر جزیره ثابت است:** مبنای درهمریزی فقط `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=<key>`
|
||
نقشهی همان کتاب را میدهد و کتابِ بیربط به پایه ۴۰۰ است؛ `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`).
|