Files
MathApp/docs/architecture.md
Zareian 11bbe1c248 tutor runs on gpt-4.1-mini, and primary-school "/" is read as decimal or fraction by rule
Two benchmark rounds (14 models on 30 problems, then gpt-4o-mini vs gpt-4.1-mini
on 102 problems under three prompt versions, hand-graded): gpt-4.1-mini scored
95-96/102 against 85-90 for gpt-4o-mini, at 0.12 vs 0.04 cents per answer.
The default now lives in settings so the deploy needs no .env edit.

The context note "in the book / is a decimal point, not a fraction" was written
for the upper-grade books but went to every grade, so gpt-4.1-mini computed
1/7 + 1/8 as 3.5. For primary grades the note now says / can be either, and a
reading rule sits next to the question itself (the same rule at the end of
the system prompt had no effect). Slash problems went from 11 to 14 of 17;
three are still wrong.

generate_answer now reports the provider's exact per-call cost, reasoning
tokens and whether the raw output broke the plain-text rule. The benchmark
fixes primary grades being sent with no book context, caches retrieval so
runs compare on identical context, and retries Qdrant timeouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 02:36:54 +03:30

26 KiB
Raw 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).

مهمانی که با شماره وارد می‌شود همان کاربر می‌ماند. اگر درخواستِ ورود توکنِ مهمان را همراه دارد و آن شماره هنوز حسابی ندارد، به همان ردیفِ مهمان شماره داده می‌شود؛ امتیاز، استریک، جزیره‌ها و گفتگوها سرِ جایشان می‌مانند. شماره‌ای که از قبل حساب دارد ادغام نمی‌شود (حسابِ موجود باز می‌شود و مهمان دست‌نخورده می‌ماند)، و 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 توضیح داده شده.

شکلِ پاسخ (پرامپتِ 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 از ۱۱ به ۱۴ از ۱۷. سه‌تا هنوز غلط است.

تمام شدنِ اعتبار دیده می‌شود. هر گذارِ provider بینِ ok و quota_exhausted یک ردیف در statuslog است (ai_tutor/provider_status.py) و /api/health آن را در integrations.aiProvider و warnings نشان می‌دهد. پیش‌تر openai: true فقط یعنی «کلید تنظیم شده».

ساختِ آزمون

اپ در سطحِ نود بازی می‌شود (جزیره روی نقشه)، ولی سوال‌ها به درس وصلند — و درس اینجا ریزترین دانه است: یک نوعِ مسئله از کتاب، نه یک جلسه‌ی کلاسی.

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. تا ۲۰۲۶-۰۹-۲۴ هر قلب ۲۴ ساعت بود؛ تایمرِ قدیمیِ بلندتر از ۵ ساعت در اولین خواندن کوتاه می‌شود.

با صفر قلب، بک‌اند فقط وقتی جواب را رد می‌کند که HEARTS_ENFORCED=1 باشد (۴۰۳ با code: "no_hearts" و nextRefillAt). پیش‌فرض خاموش است چون فرانتِ فعلی برای این پاسخ صفحه ندارد و دانش‌آموزِ بی‌قلب را به صفحه‌ی خطای عمومی می‌برد. تا روشن نشده، «قلبِ نامحدودِ» اشتراک فقط در ظاهرِ اپ اعمال می‌شود و با صدا زدنِ مستقیمِ API دور زده می‌شود.

امتیاز فقط برای جوابِ ثبت‌شده است. 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).