Files
MathApp/docs/architecture.md
Zareian a4e0c576bb Docs brought to the current state: CLAUDE.md rewritten, README formal, handbook and diagrams redrawn
- 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>
2026-10-14 18:45:51 +03:30

32 KiB
Raw Permalink Blame History

معماری

تصمیم‌هایی که از خواندنِ کد واضح نیستند و دلیلشان.

احرازِ هویت، سیستمِ خودمان است

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 توضیح داده شده.

شکلِ پاسخ

پرامپتِ فعلی 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).