tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed

Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 06:24:13 +03:30
parent fbdb9495ee
commit 42783f9ba4
56 changed files with 2792 additions and 326 deletions

View File

@@ -17,6 +17,27 @@
`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
هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد. ارجاعِ بینِ اپ‌ها با
@@ -61,6 +82,30 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
**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 سوال →
@@ -79,6 +124,32 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
کیفیتِ محتوایی که این لایه می‌خواند در
[`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` مدل‌های تمام‌شده را دوباره اجرا نمی‌کند.
**تمام شدنِ اعتبار دیده می‌شود.** هر گذارِ provider بینِ `ok` و
`quota_exhausted` یک ردیف در `statuslog` است (`ai_tutor/provider_status.py`) و
`/api/health` آن را در `integrations.aiProvider` و `warnings` نشان می‌دهد.
پیش‌تر `openai: true` فقط یعنی «کلید تنظیم شده».
## ساختِ آزمون
اپ در سطحِ **نود** بازی می‌شود (جزیره روی نقشه)، ولی سوال‌ها به **درس** وصلند —
@@ -97,9 +168,26 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
آزمونش تمام شده `completed` می‌بیند، و مسیرِ قدیمیِ «همه‌ی درس‌ها تک‌تک» هم هنوز
کار می‌کند.
**جزیره با درس‌ها هم تمام می‌شود، ولی فقط درس‌های سوال‌دار شمرده می‌شوند**
(۲۰۲۶-۰۹-۲۵). ۵۸۵ درس هنوز سوال ندارند و اپ برایشان دکمه‌ی شروع ندارد؛ با شرطِ
«همه‌ی درس‌ها» ۱۸۱ جزیره از ۲۶۹ هرگز تمام نمی‌شد. هر نود در `/api/home`
`nextLessonId` دارد (اولین درسِ سوال‌دارِ تمام‌نشده) و **عضوِ اولِ `lessonIds`
همیشه همان است** — فرانت با کلیک روی جزیره `lessonIds[0]` را باز می‌کند و پیش‌تر
هر بار درسِ اول را می‌گرفت. جزیره‌ای که یک بار تمام شد (`NodeProgress` یا
آزمون) تمام می‌ماند، حتی اگر بعداً درسی سوالِ تازه بگیرد؛ سوالِ تازه کشِ نقشه را
باطل می‌کند.
`progressPercent` در `/api/home` برای همان پایه‌ای است که نقشه‌اش نشان داده
می‌شود: درس‌های تمام‌شده به‌اضافه‌ی همه‌ی درس‌های جزیره‌های تمام‌شده، تقسیم بر
درس‌های آن پایه. (قبلاً مخرج همه‌ی ۲٬۶۱۷ درسِ همه‌ی پایه‌ها بود.)
می‌شود: درس‌های سوال‌دارِ تمام‌شده به‌اضافه‌ی درس‌های جزیره‌های تمام‌شده، تقسیم
بر درس‌های سوال‌دارِ آن پایه. (قبلاً مخرج همه‌ی ۲٬۶۱۷ درسِ همه‌ی پایه‌ها بود.)
## دستیار و سوالِ تمرینی
اگر اپ در `POST /api/ai/ask` فیلدِ `questionId` را بفرستد، دستیار صورتِ همان
سوال، گزینه‌ها، جوابِ درست و **آخرین جوابِ خودِ دانش‌آموز** را می‌بیند
(`rag.services.generation.question_focus`) و اگر غلط بوده، اول می‌گوید اشتباه در
کدام مرحله بوده. این همان «کمک در جایی که بچه‌ها گیر می‌کنند» است. این
جواب‌ها کش نمی‌شوند. `userQuestion` حداکثر ۲۰۰۰ نویسه است.
## دستیار بدونِ درس
@@ -130,14 +218,37 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
حالتِ پر کم شد، همه یک‌جا پر می‌شوند** (`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 را از شماره‌اش می‌گیرد، نه از شمارنده‌ی
دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده می‌شود. پایه‌ی

View File

@@ -110,6 +110,12 @@ curl https://api.math.tadrisino2.ir/api/health
| `FRONTEND_URL` · `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `https://math.tadrisino2.ir/?orderId=…&status=paid\|failed` برمی‌گردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی |
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد |
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
| `HEARTS_ENFORCED` | خاموش: با صفر قلب هم می‌شود جواب داد. `1` وقتی فرانت برای ۴۰۳ِ `no_hearts` صفحه‌ی «قلب‌ها تمام شد» دارد |
| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بسته‌اند |
**پرداخت‌های تأییدنشده:** اپ هر بار که وضعیتِ اشتراک را می‌خواند، سفارش‌های
pendingِ همان کاربر را دوباره با زرین‌پال می‌سنجد. برای دیدنِ همه یک‌جا:
`python manage.py payment_reconcile --days 3`.
> **`DJANGO_SECRET_KEY` توکن‌های ورود را امضا می‌کند.** بهترین حالت این است که
> اصلاً ست نشود تا خودِ تنظیمات یکی بسازد — آن‌وقت کلیدی وجود ندارد که لو