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

@@ -148,11 +148,14 @@ NUM_PROXIES=1
KAVENEGAR_SENDER=
# OTP_SMS_TEMPLATE=کد ورود شما به تدریسینو: {code}
# کلیدِ endpointهای داخلی (ingest در اپ rag) و امضای وبهوکِ پرداخت. با
# DEBUG=1 مقدارِ توسعه دارند؛ با DEBUG=0 خالی‌اند و آن مسیرها بسته می‌مانند
# کلیدِ endpointهای داخلی (ingest در اپ rag، ساختِ سفارشِ سرور-به-سرور). با
# DEBUG=1 مقدارِ توسعه دارد؛ با DEBUG=0 خالی است و آن مسیرها بسته می‌مانند
# تا وقتی مقدارِ واقعی بگذارید.
INTERNAL_SERVICE_KEY=
PAYMENT_WEBHOOK_SECRET=
# با صفر قلب جلوی جواب دادن گرفته شود؟ (۱ = بله). فقط وقتی فرانت برای ۴۰۳ِ
# no_hearts صفحه‌ی «قلب‌ها تمام شد» دارد.
# HEARTS_ENFORCED=0
# پرداختِ درون‌برنامه‌ایِ کافه‌بازار. بدونِ این چهار تا، مسیرِ بازار
# fail-closed می‌ماند (payment/bazaar.py). REFRESH_TOKEN یک‌بار و دستی از

3
.gitignore vendored
View File

@@ -17,3 +17,6 @@ staticfiles/
# نسخه‌ی پشتیبانِ تغییرهای دستیِ روی سرور که deploy.sh کنار می‌گذارد.
.deploy-backups/
# دیتابیسِ فایلیِ تست‌ها (config/settings.py → DATABASES.TEST)
.test-db.sqlite3*

View File

@@ -6,6 +6,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Django + DRF backend for Tadrisino, an educational app (Persian/Farsi). Codebase and API responses are in Persian; comments in source are Persian too.
## Reviewing this codebase: comments are claims, not evidence
Comments here were mostly written by earlier agents to justify their own change. They
explain intent; they do not prove the code is right. An audit on 2026-09-25 found real
bugs directly under comments calling the same code "safe" or "race-free" (plan swap
at checkout, `select_for_update` guards that SQLite silently ignores).
- **When asked to find problems, review adversarially.** Judge by behaviour, not by
the comment above it: write a throwaway test or call the endpoint and watch what
happens. Ask "how would a cheating student, an attacker, or a confused parent break
this?" A finding counts once it is reproduced; say which ones were only read.
- **When writing comments, report — don't vouch.** Say what the code does and why it
was chosen, and name what was *not* checked. Avoid "this is safe / correct /
race-free / the right architecture"; write "guards X; not tested under Y" instead.
Record uncertainty plainly so the next reader re-checks rather than trusts.
## Commands
```bash
@@ -46,7 +62,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
| `campaigns` | messaging campaign storage only, no send logic yet | `MessageCampaign` |
| `config` | project settings, root urls, middleware | — |
**Auth is custom, not Django's.** `accounts.User` is deliberately *not* `AUTH_USER_MODEL` — it's an independent business model. Auth is a stateless signed bearer token (`django.core.signing`, no JWT library) issued/verified in `accounts/authentication.py` (`TadrisinoTokenAuthentication`, header `Authorization: Bearer <token>`), backed by `accounts.Session`. Missing/invalid header → anonymous request, not a hard failure; each view's permission class decides if auth is required.
**Auth is custom, not Django's.** `accounts.User` is deliberately *not* `AUTH_USER_MODEL` — it's an independent business model. Auth is a stateless signed bearer token (`django.core.signing`, no JWT library) issued/verified in `accounts/authentication.py` (`TadrisinoTokenAuthentication`, header `Authorization: Bearer <token>`). The access token is only a signed `user_id` (1 h); `accounts.Session` holds refresh tokens, and `POST /api/auth/logout` revokes older access tokens via `User.tokens_valid_after`. Missing/invalid header → anonymous request, not a hard failure; each view's permission class decides if auth is required.
**Apps are decoupled by convention**: instead of real FKs across app boundaries, code uses loose refs (`user_id`, `object_id` + `label`) so any app can be split out later. Don't add cross-app FKs — follow the existing loose-reference pattern.
@@ -56,7 +72,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
**Structured logging**: `config.middleware.APIRequestLoggingMiddleware` (last in `MIDDLEWARE`, closest to the view) logs every `/api/` request as JSON (method/path/status/duration/user, no body/headers/secrets) via `config/log_formatters.py`.
**SQLite** is current DB (`OPTIONS.timeout=20` to tolerate concurrent-write locks, e.g. campaigns tests); README notes Postgres migration is still pending.
**SQLite** is current DB (`OPTIONS.timeout=20`, and `transaction_mode: IMMEDIATE` so every `atomic()` takes the write lock up front — SQLite ignores `select_for_update`, so without it those guards locked nothing). Tests run on a file DB (`.test-db.sqlite3`). Postgres migration is still pending.
**SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success.
@@ -371,7 +387,45 @@ preferred setup, because there is then no key to leak. **Never let the server ru
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
anyone who can read this repo can forge a token for any user.**
## وضعیت فعلی (۲۰۲۶-۰۹-۲۴) — این را اول بخوان
## وضعیت ۲۰۲۶-۰۹-۲۵ — این را اول بخوان
کیفیتِ دستیار و گیرِ نقشه (جزئیات و دلیل‌ها در `docs/architecture.md`). **۵۵۵ تست سبز.**
این کارها روی سرور **هنوز دیپلوی نشده‌اند**، مگر خلافش اینجا ثبت شده باشد.
- **پرامپت `tutor-v3-stepwise-plain`**: متنِ ساده (بی LaTeX/markdown، با تورِ ایمنیِ
`rag/services/mathtext.py`)، حلِ کامل و مرحله‌به‌مرحله با وارسی و «✅ جواب»، لحنِ
سه‌سطحی، حافظه‌ی ۶ پیامِ آخرِ گفتگو، و حذفِ راه‌حل‌های `disagrees` از context.
- **جزیره‌ها**: فقط درس‌های سوال‌دار شمرده می‌شوند، `nextLessonId` + `lessonIds[0]`
همیشه درسِ بعدی است، و جزیره‌ی تمام‌شده تمام می‌ماند. بدونِ این، فرانتِ فعلی
(که همیشه `lessonIds[0]` را باز می‌کند) هیچ جزیره‌ای را تمام نمی‌کرد.
- **سه‌یار** (`sigma`/`delta`/`pi`، باز شدن با ۰/۱۰۰/۳۰۰ امتیاز) با migration
`progress.0004`؛ کاتالوگ روی سرور خالی بود.
- **`/api/health` حالا تمام شدنِ اعتبارِ GapGPT را نشان می‌دهد** (`aiProvider`).
- ⛔ **اعتبارِ GapGPT در ۲۰۲۶-۰۹-۲۵ تمام شد** (وسطِ `rag_model_benchmark`؛ مانده
‎−$0.0016). تا شارژ، دستیار برای همه ۵۰۳ می‌دهد. انتخابِ مدلِ چت منتظرِ همین است:
هزینه‌ی واقعیِ هر پاسخ با پرامپتِ تازه — gpt-4o-mini ۰٫۰۴۴، gpt-4.1-mini ۰٫۱۱۶،
gpt-5-mini ۰٫۱۷۱، gpt-5.4-mini ۰٫۲۹، o4-mini ۰٫۴۱ سنت. دقتشان هنوز اندازه نشده.
**تصمیمِ صاحبِ پروژه (۰۹-۲۵): فعلاً gpt-4o-mini می‌ماند** (ارزان‌ترین). اولین
کاندیدا برای سنجش بعد از شارژ: `deepseek-v4-flash` (قیمتِ رسمی از 4o-mini کمتر،
ریاضیِ خیلی قوی‌تر، ولی فارسی و حالتِ بدونِ thinking‌اش اندازه نشده) و
`gpt-4.1-mini`. تیمِ Rag_backend برای جوابِ ریاضی `o3-mini` استفاده می‌کند.
پوشه‌ی موقتِ سنجش روی سرور: `~/mathapp-v2/.bench/` (پس از اتمام پاک شود).
- فرانت: PRِ ما (#3) بسته شد؛ هرچه فرانت باید بکند فقط در
`~/Desktop/کارهای-فرانت-تدریسینو.html` است (نه PR). شاخه‌ی محلیِ
`zareian/ai-chat-readability` فقط مرجع است.
**بازبینیِ بدبینانه‌ی همان روز و رفع‌ها** (۵۷۹ تست سبز؛ جزئیات در `docs/architecture.md`):
پلن روی سفارش (باگِ «ماهانه بده، سالانه بگیر»)، صفحه‌ی خرید اشتراکِ فعال را
دست نمی‌زند، تمدید از انقضای فعلی، یک کدِ تخفیف پیش از درگاه و کدِ ۱۰۰٪ = پرداخت،
بازسنجیِ خودکارِ پرداختِ تأییدنشده، حذفِ `/api/payment/webhook`، پورسانتِ affiliate
روی پرداختِ واقعی (مهمان بیرون)، امتیاز فقط از جوابِ ثبت‌شده، `HEARTS_ENFORCED`
(خاموش)، OTPِ ۶ رقمی با ۵ حدس، خروج از حساب (`tokens_valid_after`)، مهمانِ
ثبت‌نام‌کننده همان کاربر می‌ماند، والدِ چندفرزندی، سقفِ ورودِ ادمین، پاک‌سازیِ نامِ
نمایشی، `questionId` در دستیار، SQLite با `transaction_mode: IMMEDIATE`.
**عمداً انجام نشد** (تصمیمِ صاحبِ پروژه: برای ارائه نقطه‌ی شکست می‌سازند):
عوض کردنِ `DJANGO_SECRET_KEY`ِ سرور (همه را بیرون می‌اندازد) و مهاجرت به Postgres.
## وضعیت ۲۰۲۶-۰۹-۲۴
سرور روی `81348ce` به بعد است (سرویس واقعاً ری‌استارت شد)، **۵۳۰ تست سبز**، ۶۰ اندپوینت. **پروژه در این تاریخ بسته شد**؛ فایل‌های تحویل (گزارشِ ۲۲، راهنمای ارائه، zipِ بک‌اند) در `~/Desktop/گزارشات Tadrisino/گزارش-۲۲/` به‌روزند. آن روز کلِ
فرانت (`MathApp_frontend`، upstream `main` = `d223336`) صفحه‌به‌صفحه با API زنده

View File

@@ -71,6 +71,14 @@ def decode_access_token(token: str) -> int:
return int(value)
def _issued_at(token: str) -> int:
"""
ثانیه‌ی صدورِ توکن. قالبِ TimestampSigner «مقدار:زمان:امضا» است و زمان
base62؛ فقط بعد از unsignِ موفق صدا زده می‌شود، پس ساختار معتبر است.
"""
return signing.b62_decode(token.rsplit(':', 2)[-2])
class TadrisinoTokenAuthentication(BaseAuthentication):
"""
هدر مورد انتظار: Authorization: Bearer <access_token>
@@ -91,7 +99,10 @@ class TadrisinoTokenAuthentication(BaseAuthentication):
token = parts[1]
user_id = decode_access_token(token)
return (_get_request_user(user_id), token)
user = _get_request_user(user_id)
if user.tokens_valid_after is not None and _issued_at(token) < int(user.tokens_valid_after.timestamp()):
raise AuthenticationFailed('از حساب خارج شده‌اید؛ دوباره وارد شوید.')
return (user, token)
def authenticate_header(self, request):
return self.keyword

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-09-25 02:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_user_last_activity_at'),
]
operations = [
migrations.AddField(
model_name='otpcode',
name='failed_attempts',
field=models.PositiveSmallIntegerField(default=0),
),
migrations.AddField(
model_name='user',
name='tokens_valid_after',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -21,6 +21,10 @@ class User(models.Model):
# مهمانِ واقعی). فقط برای هدف‌گیریِ آینده‌ی کمپین‌های پیامی نگه‌داری
# می‌شه؛ در حال حاضر هیچ منطقِ دیگه‌ای به این فیلد وابسته نیست.
last_activity_at = models.DateTimeField(null=True, blank=True)
# access tokenهای صادرشده پیش از این لحظه رد می‌شوند (خروج از حساب). توکنِ
# access فقط user_id امضاشده است و به Session وصل نیست؛ بدونِ این، خروج
# فقط refresh را باطل می‌کرد و توکنِ فعلی تا یک ساعت کار می‌کرد.
tokens_valid_after = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
@@ -74,6 +78,9 @@ class OTPCode(models.Model):
code = models.CharField(max_length=6)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
# تلاش‌های غلط روی همین کد. در دیتابیس شمرده می‌شود، نه در کشِ LocMem که
# برای هر worker جداست؛ بعد از OTP_MAX_FAILED_ATTEMPTS کد باطل می‌شود.
failed_attempts = models.PositiveSmallIntegerField(default=0)
def __str__(self):
return f"otp-{self.phone}"

View File

@@ -1,5 +1,19 @@
import re
from rest_framework import serializers
# لینک، آیدیِ شبکه‌ی اجتماعی، و هر رشته‌ی ۵ رقمی یا بیشتر (شماره‌ی تلفن با
# فاصله/خط‌تیره هم) — ارقامِ فارسی و عربی هم.
_CONTACT = re.compile(
r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+'
r'|(?:[0-9۰-۹٠-٩][\s\-.]?){5,})',
re.IGNORECASE,
)
def clean_display_name(value: str) -> str:
return ' '.join(_CONTACT.sub(' ', value or '').split())[:30]
# ---- Request serializers ----
@@ -43,6 +57,14 @@ class UpdateProfileRequestSerializer(serializers.Serializer):
"""
name = serializers.CharField(max_length=100, required=False, allow_blank=True)
avatar = serializers.CharField(max_length=100, required=False, allow_blank=True)
def validate_name(self, value):
"""
نام در جدولِ رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود؛ شماره، لینک و
آیدی (راهِ تماس گرفتن با یک کودکِ غریبه) از آن حذف می‌شود. عمداً خطا
نمی‌دهد تا صفحه‌ی پروفایلِ اپ گیر نکند؛ فقط بقیه‌ی نام ذخیره می‌شود.
"""
return clean_display_name(value)
birthYear = serializers.IntegerField(required=False, allow_null=True,
min_value=1300, max_value=1500)
parentPhone = serializers.CharField(max_length=15, required=False, allow_blank=True)
@@ -127,3 +149,11 @@ class ProfileResponseSerializer(serializers.Serializer):
birthYear = serializers.IntegerField(allow_null=True)
parentPhone = serializers.CharField()
bio = serializers.CharField()
class LogoutRequestSerializer(serializers.Serializer):
refreshToken = serializers.CharField(required=False, allow_blank=True)
class LogoutResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()

View File

@@ -194,20 +194,18 @@ class RequestOTPViewTests(TestCase):
self.assertTrue(OTPCode.objects.filter(phone='+989120000002').exists())
@override_settings(DEBUG=True)
def test_generated_otp_is_always_a_4_digit_string_with_leading_zeros(self):
# ⚠️ فیکسِ این تست: تولیدِ OTP از random.randint به secrets.randbelow
# تغییر کرده؛ این تست مطمئن می‌شه فرمتِ ۴ رقمی (با صفرهای ابتدایی
# حفظ‌شده، نه یک عددِ کوتاه‌تر) دست‌نخورده مونده.
def test_generated_otp_is_always_a_6_digit_string_with_leading_zeros(self):
# ۶ رقم از ۲۰۲۶-۰۹-۲۵ (با ۴ رقم حدس زدن عملی بود)؛ صفرهای ابتدایی باید بمانند.
with patch('accounts.views.secrets.randbelow', return_value=7):
with patch('accounts.views.send_otp_sms'):
response = self.client.post(
'/api/auth/otp/request', {'phone': '+989120000003'}, format='json',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['devOtp'], '0007')
self.assertEqual(response.data['devOtp'], '000007')
otp = OTPCode.objects.get(phone='+989120000003')
self.assertEqual(otp.code, '0007')
self.assertEqual(len(otp.code), 4)
self.assertEqual(otp.code, '000007')
self.assertEqual(len(otp.code), 6)
self.assertTrue(otp.code.isdigit())
@@ -245,11 +243,11 @@ class RequestOTPStaleInvalidationTests(TestCase):
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_second_request_invalidates_first_otp(self):
phone = '+989121110001'
first_code = self._request_otp(phone, '1111')
first_code = self._request_otp(phone, '111111')
first_otp = OTPCode.objects.get(phone=phone, code=first_code)
self.assertFalse(first_otp.is_used)
self._request_otp(phone, '2222')
self._request_otp(phone, '222222')
first_otp.refresh_from_db()
self.assertTrue(first_otp.is_used)
@@ -257,8 +255,8 @@ class RequestOTPStaleInvalidationTests(TestCase):
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_newest_otp_still_works_for_login(self):
phone = '+989121110002'
self._request_otp(phone, '1111')
second_code = self._request_otp(phone, '2222')
self._request_otp(phone, '111111')
second_code = self._request_otp(phone, '222222')
response = self.client.post(
'/api/auth/login', {'phone': phone, 'otp': second_code}, format='json',
@@ -268,8 +266,8 @@ class RequestOTPStaleInvalidationTests(TestCase):
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_older_otp_is_rejected_after_new_request(self):
phone = '+989121110003'
first_code = self._request_otp(phone, '1111')
self._request_otp(phone, '2222')
first_code = self._request_otp(phone, '111111')
self._request_otp(phone, '222222')
response = self.client.post(
'/api/auth/login', {'phone': phone, 'otp': first_code}, format='json',

View File

@@ -0,0 +1,170 @@
"""
حساب، ورود و چند موردِ دیگر از بازبینیِ ۲۰۲۶-۰۹-۲۵.
"""
import time
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import OTPCode, User
from content.models import Choice, Grade, Lesson, Node, Question, Unit
from progress.models import UserHearts, UserStats
def _client(user):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return client
class LogoutTests(TestCase):
def setUp(self):
cache.clear()
def test_logout_kills_the_current_token_and_its_refresh(self):
guest = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-out'},
format='json').json()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}')
self.assertEqual(client.get('/api/user/profile').status_code, 200)
time.sleep(1.1) # زمانِ توکن ثانیه‌ای است؛ توکن باید پیش از خروج صادر شده باشد
response = client.post('/api/auth/logout', {'refreshToken': guest['refreshToken']}, format='json')
self.assertEqual(response.status_code, 200)
self.assertEqual(client.get('/api/user/profile').status_code, 401)
refreshed = APIClient().post('/api/auth/refresh', {'refreshToken': guest['refreshToken']}, format='json')
self.assertEqual(refreshed.status_code, 401)
# ورودِ دوباره کار می‌کند
again = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-out'},
format='json').json()
fresh = APIClient()
fresh.credentials(HTTP_AUTHORIZATION=f'Bearer {again["token"]}')
self.assertEqual(fresh.get('/api/user/profile').status_code, 200)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
class OtpTests(TestCase):
def setUp(self):
cache.clear()
def _request(self, phone, code='123456'):
with patch('accounts.views.secrets.randbelow', return_value=int(code)), \
patch('accounts.views.send_otp_sms'):
APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
def test_a_code_dies_after_five_wrong_guesses(self):
phone = '+989121234001'
self._request(phone)
for guess in ('000001', '000002', '000003', '000004', '000005'):
APIClient().post('/api/auth/login', {'phone': phone, 'otp': guess}, format='json')
self.assertTrue(OTPCode.objects.get(phone=phone).is_used)
right = APIClient().post('/api/auth/login', {'phone': phone, 'otp': '123456'}, format='json')
self.assertEqual(right.status_code, 400)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0,
TEST_LOGIN_PHONES={'+989120000101': '4321'})
class GuestSignupKeepsProgressTests(TestCase):
def setUp(self):
cache.clear()
guest = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-up'},
format='json').json()
self.guest_id = int(guest['userId'])
UserStats.objects.create(user_id=self.guest_id, xp=120)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}')
def _login(self, phone, otp):
return self.client.post('/api/auth/login', {'phone': phone, 'otp': otp}, format='json')
def test_the_guest_becomes_the_registered_user(self):
with patch('accounts.views.secrets.randbelow', return_value=654321), \
patch('accounts.views.send_otp_sms'):
self.client.post('/api/auth/otp/request', {'phone': '+989121234002'}, format='json')
body = self._login('+989121234002', '654321').json()
self.assertEqual(int(body['userId']), self.guest_id)
user = User.objects.get(pk=self.guest_id)
self.assertEqual((user.phone, user.is_guest), ('+989121234002', False))
self.assertEqual(UserStats.objects.get(user_id=self.guest_id).xp, 120)
def test_an_existing_account_is_not_merged(self):
existing = User.objects.create(phone='+989121234003', is_guest=False)
with patch('accounts.views.secrets.randbelow', return_value=111222), \
patch('accounts.views.send_otp_sms'):
self.client.post('/api/auth/otp/request', {'phone': '+989121234003'}, format='json')
body = self._login('+989121234003', '111222').json()
self.assertEqual(int(body['userId']), existing.id)
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
def test_demo_test_phones_never_absorb_a_guest(self):
body = self._login('+989120000101', '4321').json()
self.assertNotEqual(int(body['userId']), self.guest_id)
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
class DisplayNameTests(TestCase):
def test_contact_details_are_stripped_from_the_name(self):
user = User.objects.create(phone='09121234004', is_guest=False)
body = _client(user).patch('/api/user/profile', {'name': 'سارا 0912 345 6789 @sara'},
format='json').json()
self.assertEqual(body['name'], 'سارا')
class HeartsTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='ف', order=1), title='ن', order=1)
self.question = Question.objects.create(lesson=Lesson.objects.create(node=node, title='د'),
type='multiple_choice', prompt='؟')
self.right = Choice.objects.create(question=self.question, value='۱', is_correct=True)
self.user = User.objects.create(phone='09121234005', is_guest=False)
UserHearts.objects.create(user_id=self.user.id, current_hearts=0)
def _answer(self):
return _client(self.user).post(f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': self.right.id}, format='json')
def test_off_by_default_so_the_current_app_keeps_working(self):
self.assertEqual(self._answer().status_code, 200)
@override_settings(HEARTS_ENFORCED=True)
def test_when_enforced_no_hearts_means_no_answer(self):
response = self._answer()
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()['code'], 'no_hearts')
class FamilyTwoChildrenTests(TestCase):
def test_a_parent_of_two_can_see_each_child(self):
parent = User.objects.create(phone='09121234010', is_guest=False)
kids = []
for phone in ('09121234011', '09121234012'):
kid = User.objects.create(phone=phone, is_guest=False)
code = _client(parent).post('/api/family/link/invite', {'childPhone': phone},
format='json').json()['inviteCode']
_client(kid).post('/api/family/link/accept', {'inviteCode': code}, format='json')
kids.append(kid)
client = _client(parent)
self.assertEqual(client.get('/api/family/settings').status_code, 200)
self.assertEqual(client.get('/api/family/weekly-report').status_code, 200)
self.assertEqual(client.get(f'/api/family/settings?childUserId={kids[0].id}').status_code, 200)
self.assertEqual(client.get('/api/family/settings?childUserId=999999').status_code, 404)
class AdminLoginThrottleTests(TestCase):
def setUp(self):
cache.clear()
@override_settings(ADMIN_LOGIN_MAX_ATTEMPTS=3)
def test_password_guessing_is_capped(self):
codes = [self.client.post('/admin/login/', {'username': 'a', 'password': 'b'}).status_code
for _ in range(4)]
self.assertEqual(codes[-1], 429)
self.assertNotEqual(codes[0], 429)

View File

@@ -3,6 +3,7 @@ from django.urls import path
from .views import (
GuestSessionView,
LoginView,
LogoutView,
RefreshTokenView,
RequestOTPView,
SetUserGradeView,
@@ -14,6 +15,7 @@ urlpatterns = [
path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'),
path('auth/login', LoginView.as_view(), name='auth-login'),
path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'),
path('auth/logout', LogoutView.as_view(), name='auth-logout'),
path('user/grade', SetUserGradeView.as_view(), name='user-grade'),
path('user/profile', UserProfileView.as_view(), name='user-profile'),
]

View File

@@ -6,6 +6,7 @@ import uuid
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.db.models import F
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from rest_framework import status
@@ -14,7 +15,7 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .authentication import issue_access_token
from .authentication import invalidate_user_cache, issue_access_token
from .models import OTPCode, Profile, Session, User
from .services import OTPDeliveryError, send_otp_sms
from .serializers import (
@@ -22,6 +23,8 @@ from .serializers import (
GuestSessionResponseSerializer,
LoginRequestSerializer,
LoginResponseSerializer,
LogoutRequestSerializer,
LogoutResponseSerializer,
ProfileResponseSerializer,
RefreshRequestSerializer,
RefreshResponseSerializer,
@@ -207,7 +210,9 @@ class RequestOTPView(APIView):
# فقط آخرین کد صادرشده معتبر است.
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
code = f'{secrets.randbelow(10000):04d}'
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را می‌پذیرد.
code = f'{secrets.randbelow(1_000_000):06d}'
OTPCode.objects.create(
phone=phone,
code=code,
@@ -300,8 +305,20 @@ class LoginView(APIView):
valid = True
if not valid:
_count_failed_attempt(phone)
return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST)
user = User.objects.filter(phone=phone).first()
created = False
guest = _upgradable_guest(request, phone) if user is None else None
if guest is not None:
# مهمانی که ثبت‌نام می‌کند همان کاربر می‌ماند: امتیاز، استریک،
# جزیره‌ها و گفتگوها همه با همین user_id ذخیره شده‌اند. پیش‌تر یک
# کاربرِ تازه و خالی ساخته می‌شد و پیشرفتِ مهمان جا می‌ماند.
guest.phone = phone
guest.save(update_fields=['phone'])
user, created = guest, True
elif user is None:
user, created = User.objects.get_or_create(
phone=phone,
defaults={'is_guest': False, 'name': ''},
@@ -336,6 +353,58 @@ class LoginView(APIView):
return Response(LoginResponseSerializer(data).data)
def _count_failed_attempt(phone: str) -> None:
"""حدسِ غلط روی کدهای فعالِ این شماره؛ کدی که به سقف برسد باطل می‌شود."""
active = OTPCode.objects.filter(phone=phone, is_used=False, expires_at__gte=timezone.now())
active.update(failed_attempts=F('failed_attempts') + 1)
active.filter(failed_attempts__gte=settings.OTP_MAX_FAILED_ATTEMPTS).update(is_used=True)
def _upgradable_guest(request, phone: str):
"""
مهمانی که با توکنِ خودش درخواستِ ورود داده و این شماره هنوز حسابی ندارد.
شماره‌های TEST_LOGIN_PHONES عمداً کنار گذاشته می‌شوند: حساب‌های دمو با
همین شماره‌ها ساخته شده‌اند و ورودِ آزمایشی نباید حسابِ مهمانِ دمو
(`frontend-test-device`) را به یک شماره تبدیل کند.
"""
if phone in settings.TEST_LOGIN_PHONES:
return None
user = getattr(request, 'user', None)
if user is None or not getattr(user, 'is_authenticated', False) or not user.is_guest:
return None
return User.objects.filter(pk=user.pk, is_guest=True, phone__isnull=True).first()
class LogoutView(APIView):
"""
POST /api/auth/logout — خروج از حساب.
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل می‌شود و همه‌ی access
tokenهای صادرشده تا این لحظه رد می‌شوند. دستگاه‌های دیگر با refreshِ
خودشان بی‌صدا توکنِ تازه می‌گیرند؛ فقط همین دستگاه بیرون می‌ماند.
"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=LogoutRequestSerializer, responses=LogoutResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def post(self, request):
serializer = LogoutRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
refresh_token = serializer.validated_data.get('refreshToken') or ''
with transaction.atomic():
if refresh_token:
Session.objects.filter(
user_id=request.user.id, refresh_token=refresh_token,
).update(revoked=True)
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
invalidate_user_cache(request.user.id)
return Response(LogoutResponseSerializer({'success': True}).data)
class RefreshTokenView(APIView):
"""POST /api/auth/refresh — تمدید توکن"""

View File

@@ -4,6 +4,9 @@ from rest_framework import serializers
class AffiliateMeResponseSerializer(serializers.Serializer):
referralCode = serializers.CharField()
commissionRate = serializers.FloatField()
referredCount = serializers.IntegerField(help_text='کاربرهایی که کدِ شما را ثبت کرده‌اند.')
paidCount = serializers.IntegerField(help_text='از میانِ آن‌ها، کسانی که واقعاً پرداخت کرده‌اند.')
totalCommission = serializers.IntegerField(help_text='جمعِ پورسانتِ پرداخت‌های واقعی (تومان).')
class TrackRequestSerializer(serializers.Serializer):

43
affiliate/services.py Normal file
View File

@@ -0,0 +1,43 @@
"""
پورسانتِ معرف: فقط روی پرداختِ واقعی، فقط یک بار برای هر کاربرِ معرفی‌شده.
تا ۲۰۲۶-۰۹-۲۵ هیچ کدی Conversion را از «pending» جلو نمی‌برد و پورسانتی
حساب نمی‌شد؛ برنامه‌ی معرفی فقط یک جدولِ ثبت بود. حالا اولین سفارشِ
پرداخت‌شده‌ی کاربرِ معرفی‌شده Conversion را `paid` می‌کند و
`commission_amount = مبلغِ پرداختی × commission_rate` را ثبت می‌کند.
پرداختِ خودِ پورسانت به معرف (تسویه) هنوز دستی و از پنلِ ادمین است.
ثبت‌نام یا ورودِ مهمان هیچ پورسانتی نمی‌سازد؛ فقط پول جابه‌جا شدنِ واقعی.
"""
import logging
from django.db import transaction
from .models import Conversion
logger = logging.getLogger(__name__)
def record_paid_order(order) -> Conversion | None:
"""از apply_order_payment_result صدا زده می‌شود، داخلِ همان تراکنش."""
if not order.amount:
return None
try:
with transaction.atomic():
conversion = (
Conversion.objects.select_for_update()
.select_related('affiliate_account')
.filter(referred_user_id=order.user_id, status='pending')
.first()
)
if conversion is None:
return None
rate = conversion.affiliate_account.commission_rate or 0
conversion.status = 'paid'
conversion.order_id = order.id
conversion.commission_amount = int(order.amount * rate)
conversion.save(update_fields=['status', 'order_id', 'commission_amount'])
return conversion
except Exception: # noqa: BLE001 — پورسانت نباید تاییدِ پرداختِ خودِ کاربر را خراب کند
logger.exception('affiliate: ثبتِ پورسانتِ سفارش %s شکست خورد', order.id)
return None

View File

@@ -1,6 +1,6 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
@@ -41,6 +41,16 @@ class GuestAwareAffiliateThrottle(ScopedRateThrottle):
return self.cache_format % {'scope': self.scope, 'ident': ident}
def _reject_guest(user):
"""
مهمان نه کدِ معرف می‌گیرد نه معرفی‌شده حساب می‌شود: حسابِ مهمان با هر
deviceId تازه ساخته می‌شود، پس شمردنش فقط آمار را باد می‌کرد. بعد از ورود
با شماره، همان کاربر (با همان پیشرفت) می‌تواند کد را ثبت کند.
"""
if getattr(user, 'is_guest', False):
raise PermissionDenied('برنامه‌ی معرفی بعد از ورود با شماره‌ی موبایل فعال می‌شود.')
class AffiliateMeView(APIView):
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
@@ -48,9 +58,16 @@ class AffiliateMeView(APIView):
@extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)'])
def get(self, request):
_reject_guest(request.user)
account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id)
data = {'referralCode': account.referral_code, 'commissionRate': account.commission_rate}
paid = account.conversions.filter(status='paid')
data = {
'referralCode': account.referral_code,
'commissionRate': account.commission_rate,
'referredCount': account.conversions.count(),
'paidCount': paid.count(),
'totalCommission': sum(paid.values_list('commission_amount', flat=True)),
}
return Response(AffiliateMeResponseSerializer(data).data)
@@ -83,7 +100,7 @@ class TrackConversionView(APIView):
serializer = TrackRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
_reject_guest(request.user)
account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode'])
referred_user_id = request.user.id

View File

@@ -0,0 +1,65 @@
"""
وضعیتِ provider هوش مصنوعی (GapGPT): «کار می‌کند» یا «اعتبار تمام شده».
چرا لازم است: ۲۰۲۶-۰۹-۲۵ اعتبارِ GapGPT تمام شد و دستیار برای همه‌ی کاربرها
۵۰۳ داد («دستیار الان در دسترس نیست»)، در حالی که /api/health همچنان
`openai: true` نشان می‌داد — چون فقط «کلید تنظیم شده» را می‌دید. تنها راهِ
فهمیدنش خواندنِ لاگِ سرور بود.
حالا هر گذار (ok → quota_exhausted و برعکس) یک ردیف در statuslog می‌شود:
همه‌ی workerها آن را می‌بینند (کشِ LocMem این‌طور نیست) و /api/health
گزارشش می‌کند. فقط گذار ثبت می‌شود، نه هر درخواست.
"""
import logging
from django.core.cache import cache
logger = logging.getLogger(__name__)
LABEL = 'ai_tutor.Provider'
OK = 'ok'
EXHAUSTED = 'quota_exhausted'
_CACHE_KEY = 'ai_tutor:provider_state'
def current_state() -> str:
state = cache.get(_CACHE_KEY)
if state is None:
from statuslog.models import StatusChangeLog
state = (
StatusChangeLog.objects.filter(label=LABEL)
.values_list('new_status', flat=True).first()
) or OK
cache.set(_CACHE_KEY, state, 60)
return state
def _transition(new_state: str, event: str) -> None:
from statuslog.signals import status_changed
old_state = current_state()
if old_state == new_state:
return
status_changed.send(sender=LABEL, object_id=0, old_status=old_state,
new_status=new_state, event=event)
cache.set(_CACHE_KEY, new_state, 60)
def record_failure(exc) -> None:
"""خطای provider؛ فقط تمام‌شدنِ اعتبار/سقفِ حساب وضعیت را عوض می‌کند."""
from rag.services.clients import is_account_limit
try:
if is_account_limit(exc):
_transition(EXHAUSTED, 'provider_account_limit')
except Exception: # noqa: BLE001 — گزارش‌دهی نباید پاسخِ ۵۰۳ را خراب کند
logger.exception('provider_status: ثبتِ خطا شکست خورد')
def record_success() -> None:
try:
if current_state() != OK:
_transition(OK, 'provider_recovered')
except Exception: # noqa: BLE001
logger.exception('provider_status: ثبتِ بازگشت شکست خورد')

View File

@@ -18,7 +18,9 @@ class AskRequestSerializer(serializers.Serializer):
# RAG_GRADE_TITLE_OVERRIDES ساخته می‌شه.
grade = serializers.CharField(required=False, allow_null=True, allow_blank=True)
lessonNumber = serializers.IntegerField(required=False, allow_null=True)
userQuestion = serializers.CharField()
# سقفِ طول: کلِ متن به مدلِ پولی می‌رود؛ بی‌سقف، یک درخواستِ صدهزار نویسه‌ای
# به‌تنهایی هزینه‌ی صدها سوالِ عادی را داشت.
userQuestion = serializers.CharField(max_length=2000)
stream = serializers.BooleanField(required=False, default=False)

View File

@@ -16,6 +16,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from accounts.models import User as AccountUser
from . import provider_status
from .models import AskIdempotency, Conversation, Message, MessageFeedback
from .serializers import (
AskRequestSerializer,
@@ -103,6 +104,43 @@ def _is_db_ref_id(value) -> bool:
return str(value).strip().lstrip('-').isdigit()
def _conversation_history(conversation, before_message) -> list[dict]:
"""
چند پیامِ آخرِ همین گفتگو، پیش از سوالِ تازه، به ترتیبِ زمان.
بدونِ این، هر سوال جدا از قبلی‌ها جواب داده می‌شد: «نفهمیدم، ساده‌تر بگو»
برای مدل هیچ مرجعی نداشت.
"""
from rag.services.generation import HISTORY_TURNS
rows = list(
Message.objects.filter(conversation=conversation, id__lt=before_message.id)
.order_by('-id').values('role', 'text')[:HISTORY_TURNS]
)
return list(reversed(rows))
def _retrieval_query(question: str, history: list[dict], focus: str = '') -> str:
"""
متنی که embedding می‌شود. «یه مثال دیگه بزن» به‌تنهایی به هیچ تکه‌ای از
کتاب نزدیک نیست؛ سوالِ قبلیِ کاربر (یا سوالِ تمرینیِ اپ) موضوع را معلوم می‌کند.
"""
if focus:
return f'{focus}\n{question}'
previous = next((t['text'] for t in reversed(history) if t['role'] == 'user'), '')
return f'{previous}\n{question}' if previous else question
def _question_focus(v: dict, user) -> str:
"""اگر اپ questionId فرستاده، متنِ همان سوال و جوابِ دانش‌آموز (rag.generation)."""
question_id = v.get('questionId')
if not _is_db_ref_id(question_id):
return ''
from rag.services.generation import question_focus
return question_focus(question_id, user.id)
class AIProviderUnavailable(APIException):
"""
⚠️ افزوده شده: وقتی generate_tutor_answer به یک AI provider واقعی
@@ -330,9 +368,12 @@ class AskAIView(APIView):
from rag.services.generation import generate_answer
from rag.services.retrieval import resolve_target, retrieve_context
history = _conversation_history(conversation, user_message)
focus = _question_focus(v, user)
cacheable = answer_cache.cacheable(history) and not focus
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
cached = answer_cache.get(cache_key)
cached = answer_cache.get(cache_key) if cacheable else None
if cached is not None:
answer_text = cached['answer']
follow_ups = list(cached['followUps'])
@@ -342,7 +383,8 @@ class AskAIView(APIView):
answer_from_cache = True
else:
used_chunks, retrieval_meta = retrieve_context(
v['userQuestion'], subject=subject, grade_id=grade_id,
_retrieval_query(v['userQuestion'], history, focus),
subject=subject, grade_id=grade_id,
grade_title=grade_title, lesson_number=lesson_number,
)
used_chunks = [
@@ -352,7 +394,10 @@ class AskAIView(APIView):
answer_text, follow_ups, generation_meta = generate_answer(
v['userQuestion'], used_chunks or None,
retrieval_meta.get('gradeTitle') or grade_title or '',
history=history, focus=focus,
)
provider_status.record_success()
if cacheable:
answer_cache.set(cache_key, {
'answer': answer_text, 'followUps': follow_ups,
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
@@ -362,8 +407,9 @@ class AskAIView(APIView):
answer_text, follow_ups = generate_tutor_answer(
v['userQuestion'], grade_id, lesson_id, context
)
except Exception:
except Exception as exc:
logger.exception('generate_tutor_answer failed for user_id=%s', user.id)
provider_status.record_failure(exc)
self._release_reserved_slot(conversation, user_message, conversation_is_new)
raise AIProviderUnavailable()
@@ -436,7 +482,9 @@ class AskAIView(APIView):
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
"""
from rag.services import answer_cache
from rag.services.generation import PROMPT_VERSION, default_follow_ups, stream_answer
from rag.services.generation import (
PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer,
)
from rag.services.retrieval import resolve_target, retrieve_context
def _sse(event: str, payload: dict) -> str:
@@ -449,7 +497,10 @@ class AskAIView(APIView):
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
try:
cached = answer_cache.get(cache_key)
history = _conversation_history(conversation, user_message)
focus = _question_focus(v, user)
cacheable = answer_cache.cacheable(history) and not focus
cached = answer_cache.get(cache_key) if cacheable else None
if cached is not None:
answer_text = cached['answer']
follow_ups = list(cached['followUps'])
@@ -460,7 +511,8 @@ class AskAIView(APIView):
yield _sse('chunk', {'delta': answer_text})
else:
used_chunks, retrieval_meta = retrieve_context(
v['userQuestion'], subject=subject, grade_id=grade_id,
_retrieval_query(v['userQuestion'], history, focus),
subject=subject, grade_id=grade_id,
grade_title=grade_title, lesson_number=lesson_number,
)
used_chunks = [
@@ -469,30 +521,34 @@ class AskAIView(APIView):
]
parts: list[str] = []
started = time.monotonic()
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
for delta in stream_answer(
v['userQuestion'], used_chunks or None,
retrieval_meta.get('gradeTitle') or grade_title or '',
v['userQuestion'], used_chunks or None, effective_grade,
history=history, focus=focus,
):
parts.append(delta)
yield _sse('chunk', {'delta': delta})
provider_status.record_success()
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
follow_ups = default_follow_ups()
# مسیرِ استریم usage برنمی‌گرداند (پاسخ توکن‌به‌توکن است)،
# پس فقط چیزی را ثبت می‌کنیم که واقعاً می‌دانیم؛ توکن‌ها صفر
# می‌مانند و گزارشِ هزینه هم همین را نشان می‌دهد.
generation_meta = {
'model': settings.OPENAI_CHAT_MODEL,
'model': chat_model_for(effective_grade),
'promptVersion': PROMPT_VERSION,
'latencyMs': int((time.monotonic() - started) * 1000),
'hadContext': bool(used_chunks),
}
if cacheable:
answer_cache.set(cache_key, {
'answer': answer_text, 'followUps': follow_ups,
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
'generationMeta': generation_meta,
})
except Exception:
except Exception as exc:
logger.exception('stream generate failed for user_id=%s', user.id)
provider_status.record_failure(exc)
self._release_reserved_slot(conversation, user_message, conversation_is_new)
yield _sse('error', {'detail': AIProviderUnavailable.default_detail})
return

View File

@@ -108,14 +108,33 @@ def _traffic() -> dict:
def _check_integrations() -> dict:
"""فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد."""
"""
فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد. `aiProvider` وضعیتِ واقعیِ
آخرین تماس است: کلیدِ تنظیم‌شده با اعتبارِ تمام‌شده هم `openai: true` بود.
"""
return {
'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')),
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
'aiProvider': _ai_provider_state(),
'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''),
'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None,
}
def _ai_provider_state() -> dict:
try:
from statuslog.models import StatusChangeLog
row = StatusChangeLog.objects.filter(label='ai_tutor.Provider').values(
'new_status', 'created_at').first()
except Exception as exc: # noqa: BLE001
return {'error': type(exc).__name__}
if row is None:
return {'state': 'ok', 'since': None}
return {'state': row['new_status'], 'since': row['created_at'].isoformat()}
def _warnings() -> list[str]:
"""
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند.
@@ -136,10 +155,17 @@ def _warnings() -> list[str]:
if test_phones:
# نه خودِ شماره و نه کد برنمی‌گردد؛ فقط تعداد، تا کسی که تنظیماتِ سرور
# را ندیده هم بداند چند حسابِ تست با کدِ ثابت باز است و بتواند ببنددشان.
# تعداد عمداً گفته نمی‌شود: این صفحه عمومی است و «۷ حسابِ کدِ ثابت» برای
# مهاجم یعنی هدفِ مشخص برای حدس زدن.
out.append(
f'{len(test_phones)} شماره‌ی تستِ ورود با کدِ ثابت تعریف شده (TEST_LOGIN_PHONES). '
'شماره‌های تستِ ورود با کدِ ثابت فعال‌اند (TEST_LOGIN_PHONES). '
'برای توسعه لازم است؛ پیش از تحویلِ نهایی به کاربرانِ واقعی حذفشان کنید.'
)
if _ai_provider_state().get('state') == 'quota_exhausted':
out.append(
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها '
'«در دسترس نیست» می‌دهد تا حساب شارژ شود.'
)
backend = settings.CACHES['default']['BACKEND']
if not settings.DEBUG and 'LocMem' in backend:
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')

View File

@@ -110,3 +110,42 @@ class APINoStoreMiddleware:
if request.path.startswith(_LOGGED_PATH_PREFIX) and not response.has_header('Cache-Control'):
response['Cache-Control'] = 'private, no-store'
return response
class AdminLoginThrottleMiddleware:
"""
سقفِ تلاشِ ورود به /admin/ برای هر IP (ADMIN_LOGIN_MAX_ATTEMPTS در ۱۵ دقیقه).
پنلِ ادمین روی دامنه‌ی عمومی است و فرمِ ورودِ جنگو هیچ محدودیتی ندارد؛
یعنی رمزِ ادمین (که همه‌ی محتوا و پرداخت‌ها را کنترل می‌کند) بی‌سقف قابلِ
حدس زدن بود. شمارش در کشِ پیش‌فرض است: تا CACHE_URL (Redis) تنظیم نشده،
برای هر worker جدا — یعنی سقفِ واقعی تا سه برابر. IP با همان منطقِ
NUM_PROXIESِ throttleهای DRF خوانده می‌شود.
"""
WINDOW_SECONDS = 15 * 60
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.method == 'POST' and request.path.startswith('/admin/login'):
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse
from rest_framework.throttling import BaseThrottle
key = f'admin_login:{BaseThrottle().get_ident(request)}'
cache.add(key, 0, self.WINDOW_SECONDS)
try:
attempts = cache.incr(key)
except ValueError:
cache.set(key, 1, self.WINDOW_SECONDS)
attempts = 1
if attempts > getattr(settings, 'ADMIN_LOGIN_MAX_ATTEMPTS', 10):
logger.warning('admin login throttled: ident=%s attempts=%s', key, attempts)
return HttpResponse(
'تلاشِ ورود بیش از حد مجاز است؛ ۱۵ دقیقه‌ی دیگر دوباره امتحان کنید.',
status=429, content_type='text/plain; charset=utf-8',
)
return self.get_response(request)

View File

@@ -219,6 +219,8 @@ MIDDLEWARE = [
# ۴۰۰) هدرِ CORS نمی‌گیرند و در مرورگر به‌شکلِ خطای مبهمِ شبکه دیده می‌شوند.
'corsheaders.middleware.CorsMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
# سقفِ حدسِ رمزِ /admin/ برای هر IP (config/middleware.py).
'config.middleware.AdminLoginThrottleMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -269,11 +271,25 @@ DATABASES = {
# اینکه فوراً OperationalError('database table is locked') بده.
'OPTIONS': {
'timeout': 20,
# هر atomic() قفلِ نوشتن را از اول می‌گیرد (BEGIN IMMEDIATE).
# SQLite از SELECT ... FOR UPDATE پشتیبانی نمی‌کند و جنگو
# select_for_update را رویش بی‌صدا نادیده می‌گیرد؛ پس محافظ‌های
# «قفلِ ردیف» در پرداخت، تخفیف، قلب و سهمیه‌ی دستیار بدونِ این
# عملاً قفلی نداشتند. با IMMEDIATE کلِ تراکنش سریالی می‌شود (قفلِ
# کلِ دیتابیس، نه ردیف) — روی SQLite درشت‌تر ولی درست. هزینه:
# تراکنش‌ها پشتِ هم صف می‌کشند؛ فراخوانی‌های شبکه‌ای (AI، زرین‌پال)
# عمداً بیرون از atomic() هستند. بعد از Postgres این را بردارید.
'transaction_mode': 'IMMEDIATE',
},
# کانکشنِ DB به‌ازای هر request باز/بسته نشه؛ تا این تعداد ثانیه
# persistent بمونه. با SQLite بی‌اثرِ عملی ولی بی‌ضرره؛ بعد از
# مهاجرت به Postgres جلوی هزینه‌ی TCP+auth در هر request رو می‌گیره.
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
# تست‌ها روی فایل، مثلِ پروداکشن. دیتابیسِ پیش‌فرضِ تست در حافظه با
# shared-cache است که قفلش جدول‌به‌جدول و بی‌انتظار است («table is
# locked») — رفتاری که روی فایلِ واقعی پیش نمی‌آید؛ تستِ هم‌زمانیِ
# campaigns با BEGIN IMMEDIATE فقط همین‌جا می‌افتاد.
'TEST': {'NAME': str(BASE_DIR / '.test-db.sqlite3')},
}
}
@@ -584,6 +600,9 @@ OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
# (که جلویِ burst رو نمی‌گیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
# هر کدِ ورود بعد از این تعداد حدسِ غلط باطل می‌شود (شمارش در دیتابیس).
# با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون.
OTP_MAX_FAILED_ATTEMPTS = 5
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
@@ -640,6 +659,15 @@ OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به
# می‌شه که نباید فوراً به‌عنوان «رهاکردن درس» حساب بشه.
NODE_PROGRESS_ABANDON_DAYS = 14
# قلبِ صفر جلوی جواب دادن را در سرور بگیرد؟ (content.views.QuestionAnswerView)
# خاموش تا وقتی فرانت برای ۴۰۳ِ `no_hearts` صفحه‌ی «قلب‌ها تمام شد» و زمانِ
# nextRefillAt را نشان دهد؛ روشن کردنش پیش از آن، دانش‌آموزِ بی‌قلب را به
# صفحه‌ی خطای عمومی می‌برد. تا آن موقع «قلبِ نامحدودِ» اشتراک فقط در اپ است.
HEARTS_ENFORCED = os.environ.get('HEARTS_ENFORCED', '0') == '1'
# تلاشِ ورودِ ادمین در ۱۵ دقیقه برای هر IP.
ADMIN_LOGIN_MAX_ATTEMPTS = 10
# -----------------------------------------------------------------------
# Payment security
# -----------------------------------------------------------------------
@@ -651,13 +679,6 @@ INTERNAL_SERVICE_KEY = os.environ.get(
'dev-internal-service-key-change-me' if DEBUG else '',
)
# POST /api/payment/webhook باید از درگاه پرداخت بیاید، نه یک کلاینت دلخواه.
# فعلاً یک shared-secret ساده (X-Webhook-Secret) استفاده می‌شود؛ در اتصال
# واقعی باید با بررسی امضای HMAC درگاه جایگزین شود.
PAYMENT_WEBHOOK_SECRET = os.environ.get(
'PAYMENT_WEBHOOK_SECRET',
'dev-webhook-secret-change-me' if DEBUG else '',
)
# -----------------------------------------------------------------------
# Bazaar In-App Billing (پرداخت درون‌برنامه‌ای کافه‌بازار — REST API v2)
@@ -724,7 +745,14 @@ if ZARINPAL_REDIRECT_URL == '-':
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1')
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4o-mini')
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4o')
# اختیاری: مدلِ جدا فقط برای پایه‌های دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
# همه‌ی پایه‌ها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده می‌شود.
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
# فقط برای مدل‌های reasoning (o-series/gpt-5): low | medium | high؛ خالی = پیش‌فرضِ مدل.
OPENAI_CHAT_REASONING_EFFORT = os.environ.get('OPENAI_CHAT_REASONING_EFFORT', 'low')
# gpt-4.1-mini در سنجشِ ۲۰۲۶-۰۹-۲۰ هم ارزان‌تر و هم دقیق‌تر از gpt-4o بود
# (docs/extraction-playbook.md)؛ پیش‌فرض همان است تا به .env وابسته نباشد.
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4.1-mini')
# نباید تغییر کنه مگر اینکه collectionهای Qdrant هم با همون مدلِ جدید بازساخته بشن.
OPENAI_EMBEDDING_MODEL = os.environ.get('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small')
OPENAI_EMBEDDING_DIM = int(os.environ.get('OPENAI_EMBEDDING_DIM', '1536'))

View File

@@ -1,7 +1,7 @@
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .models import Grade, Lesson, Node, Unit, UserAnswer, UserLessonCompletion
from .models import Grade, Lesson, Node, Question, Unit, UserAnswer, UserLessonCompletion
from .services import touch_node_progress_on_answer, update_node_progress_on_lesson_completion
@@ -29,6 +29,10 @@ def _on_lesson_completion_created(sender, instance, created, **kwargs):
@receiver(post_delete, sender=Node, dispatch_uid='content_bump_curriculum_node_delete')
@receiver(post_save, sender=Lesson, dispatch_uid='content_bump_curriculum_lesson_save')
@receiver(post_delete, sender=Lesson, dispatch_uid='content_bump_curriculum_lesson_delete')
# سوال هم: نقشه فقط درس‌های سوال‌دار را برای تمام شدنِ جزیره می‌شمارد، پس
# اولین سوالِ یک درس (از ادمین یا import) باید نقشه‌ی کش‌شده را باطل کند.
@receiver(post_save, sender=Question, dispatch_uid='content_bump_curriculum_question_save')
@receiver(post_delete, sender=Question, dispatch_uid='content_bump_curriculum_question_delete')
def _bump_curriculum_cache_version(sender, **kwargs):
from .views import bump_curriculum_version
bump_curriculum_version()

View File

@@ -32,7 +32,20 @@ class NodeStatusSerializer(serializers.Serializer):
status = serializers.ChoiceField(choices=['locked', 'unlocked', 'completed'])
# شناسه‌ی درس‌های همین گره تا کلاینت بدونه با کلیک روی گره کدوم درس رو
# باز کنه (endpointهای lessons/{id}/... مستقیم با همین id کار می‌کنن).
lessonIds = serializers.ListField(child=serializers.IntegerField())
# ⚠️ عضوِ اول همیشه nextLessonId است؛ بقیه به ترتیبِ کتاب. فرانتِ فعلی با
# کلیک روی جزیره lessonIds[0] را باز می‌کند، پس با همین ترتیب هر بار درسِ
# بعدی باز می‌شود — پیش‌تر همیشه درسِ اول باز می‌شد و جزیره هرگز تمام نمی‌شد.
lessonIds = serializers.ListField(
child=serializers.IntegerField(),
help_text='عضوِ اول = nextLessonId؛ بقیه به ترتیبِ کتاب.',
)
nextLessonId = serializers.IntegerField(
allow_null=True,
help_text=(
'درسی که با کلیک روی این جزیره باید باز شود: اولین درسِ تمام‌نشده‌ای '
'که سوال دارد؛ اگر همه تمام شده‌اند، اولین درس (برای مرور).'
),
)
class UnitWithNodesSerializer(serializers.Serializer):

View File

@@ -6,6 +6,7 @@ content/receivers.py صدا زده می‌شن (نه مستقیم از views.py)
دقیقاً همون الگوی signal-based که برای discount/subscription استفاده شده.
"""
from django.db import transaction
from django.db.models import Count
from django.utils import timezone
from statuslog.signals import status_changed
@@ -53,6 +54,39 @@ def touch_node_progress_on_answer(user_id, question):
return progress
def playable_lesson_ids(lessons_with_counts) -> list[int]:
"""
[(lesson_id, تعدادِ سوال), ...] → idهای درس‌های سوال‌دار، به همان ترتیب.
۵۸۵ درس از ۲٬۶۲۴ (۲۰۲۶-۰۹-۲۴) هنوز سوال ندارند و اپ برایشان فقط «به‌زودی»
نشان می‌دهد، پس هیچ‌وقت تمام نمی‌شوند. اگر شرطِ تمام شدنِ جزیره «همه‌ی
درس‌ها» باشد، ۱۸۱ جزیره از ۲۶۹ هرگز تمام نمی‌شد. جزیره‌ای که هیچ درسِ
سوال‌داری ندارد، همه‌ی درس‌هایش را نگه می‌دارد (رفتارِ قبلی).
"""
pairs = list(lessons_with_counts)
playable = [lid for lid, count in pairs if count]
return playable or [lid for lid, _count in pairs]
def node_playable_lesson_ids(node) -> list[int]:
return playable_lesson_ids(
node.lessons.annotate(question_total=Count('questions'))
.values_list('id', 'question_total')
)
def verified_correct_count(user_id, *, lesson_id=None, node_id=None) -> int:
"""تعدادِ سوال‌های متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده."""
from .models import UserAnswer
qs = UserAnswer.objects.filter(user_id=user_id, correct=True)
if lesson_id is not None:
qs = qs.filter(question__lesson_id=lesson_id)
if node_id is not None:
qs = qs.filter(question__lesson__node_id=node_id)
return qs.values('question_id').distinct().count()
def update_node_progress_on_lesson_completion(user_id, lesson):
"""
وقتی یک Lesson تکمیل می‌شه، تعداد Lessonهای تکمیل‌شده‌ی Node رو
@@ -65,7 +99,7 @@ def update_node_progress_on_lesson_completion(user_id, lesson):
old_status = progress.status
now = timezone.now()
lesson_ids = list(node.lessons.values_list('id', flat=True))
lesson_ids = node_playable_lesson_ids(node)
completed_count = UserLessonCompletion.objects.filter(
user_id=user_id, lesson_id__in=lesson_ids
).values('lesson_id').distinct().count()
@@ -129,9 +163,10 @@ def grade_progress_percent(user_id, grade_id):
"""
if not grade_id:
return 0
lesson_ids = set(
Lesson.objects.filter(node__unit__grade_id=grade_id).values_list('id', flat=True)
)
lesson_ids = set(playable_lesson_ids(
Lesson.objects.filter(node__unit__grade_id=grade_id)
.annotate(question_total=Count('questions')).values_list('id', 'question_total')
))
if not lesson_ids:
return 0
done = set(
@@ -142,6 +177,7 @@ def grade_progress_percent(user_id, grade_id):
user_id=user_id, node__unit__grade_id=grade_id,
).values_list('node_id', flat=True)
done |= set(Lesson.objects.filter(node_id__in=quiz_nodes).values_list('id', flat=True))
done &= lesson_ids
return round(min(len(done) / len(lesson_ids), 1.0) * 100, 2)

View File

@@ -28,11 +28,19 @@ class LessonCompleteImpossibleCountTests(TestCase):
node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...')
for i in range(3):
Question.objects.create(
question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
Choice.objects.create(question=question, value='درست', is_correct=True)
def _complete(self, correct_count, total_questions):
# مثلِ اپ: جواب‌ها اول ثبت می‌شوند؛ امتیاز فقط برای جوابِ ثبت‌شده است.
for question in self.lesson.questions.order_by('id')[:max(correct_count, 0)]:
self.client.post(
f'/api/questions/{question.id}/answer',
{'selectedOptionId': str(question.choices.get(is_correct=True).id)},
format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': correct_count, 'totalQuestions': total_questions},
@@ -79,11 +87,19 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
node = Node.objects.create(unit=unit, title='گره ۲', order=1)
self.lesson = Lesson.objects.create(node=node, title='درس ۲', intro_text='...')
for i in range(3):
Question.objects.create(
question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
Choice.objects.create(question=question, value='درست', is_correct=True)
def _complete(self, correct_count, total_questions):
# مثلِ اپ: جواب‌ها اول ثبت می‌شوند؛ امتیاز فقط برای جوابِ ثبت‌شده است.
for question in self.lesson.questions.order_by('id')[:max(correct_count, 0)]:
self.client.post(
f'/api/questions/{question.id}/answer',
{'selectedOptionId': str(question.choices.get(is_correct=True).id)},
format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': correct_count, 'totalQuestions': total_questions},
@@ -595,7 +611,8 @@ class HomeViewScalabilityTests(TestCase):
self.assertEqual(self._home(g.id).status_code, 200)
sql = ' '.join(e['sql'] for e in q).lower()
self.assertNotIn('content_unit', sql)
self.assertNotIn('content_node', sql)
# content_nodeprogress دیتای کاربر است (جزیره‌های تمام‌شده)، نه برنامه‌ی درسی.
self.assertNotRegex(sql, r'content_node(?!progress)')
# فقط کوئریِ per-user باید بمونه
self.assertIn('userlessoncompletion', sql.replace('_', ''))

View File

@@ -0,0 +1,96 @@
"""
جزیره‌ها روی نقشه باید با بازی کردنِ درس‌ها واقعاً تمام شوند (۲۰۲۶-۰۹-۲۵).
دو چیز با هم جلوی پیشرفت را گرفته بود:
۱. جزیره فقط وقتی تمام می‌شد که *همه‌ی* درس‌هایش تمام شده باشند، ولی ۵۸۵ درس
هنوز سوال ندارند و اپ برایشان دکمه‌ی شروع ندارد — ۱۸۱ جزیره از ۲۶۹.
۲. فرانت با کلیک روی جزیره همیشه lessonIds[0] را باز می‌کرد، یعنی درسِ اول
را بارها و بارها.
"""
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import Choice, Grade, Lesson, Node, Question, Unit
class IslandProgressTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.first = Node.objects.create(unit=unit, title='جزیره‌ی اول', order=1)
self.second = Node.objects.create(unit=unit, title='جزیره‌ی دوم', order=2)
self.a = self._lesson(self.first, 'الف', with_question=True)
self.empty = self._lesson(self.first, 'بی‌سوال', with_question=False)
self.b = self._lesson(self.first, 'ب', with_question=True)
self._lesson(self.second, 'ج', with_question=True)
user = User.objects.create(phone='09120000950', grade_id=grade.id)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def tearDown(self):
cache.clear()
def _lesson(self, node, title, *, with_question):
lesson = Lesson.objects.create(node=node, title=title, intro_text='x', mascot_animation='x')
if with_question:
self._question(lesson)
return lesson
def _question(self, lesson):
question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
Choice.objects.create(question=question, value='۱', is_correct=True)
Choice.objects.create(question=question, value='۲', is_correct=False)
def _nodes(self):
units = self.client.get('/api/home').json()['units']
return {n['title']: n for n in units[0]['nodes']}
def _complete(self, lesson):
response = self.client.post(f'/api/lessons/{lesson.id}/complete',
{'correctCount': 1, 'totalQuestions': 1}, format='json')
self.assertEqual(response.status_code, 200, response.content)
def test_tapping_the_island_opens_the_next_unfinished_lesson(self):
nodes = self._nodes()
self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.a.id)
self.assertEqual(nodes['جزیره‌ی اول']['lessonIds'][0], self.a.id)
self._complete(self.a)
first = self._nodes()['جزیره‌ی اول']
# درسِ بی‌سوال رد می‌شود؛ lessonIds[0] همان است که فرانت باز می‌کند.
self.assertEqual(first['nextLessonId'], self.b.id)
self.assertEqual(first['lessonIds'][0], self.b.id)
self.assertCountEqual(first['lessonIds'], [self.a.id, self.empty.id, self.b.id])
def test_a_lesson_without_questions_does_not_hold_the_island(self):
self._complete(self.a)
self._complete(self.b)
nodes = self._nodes()
self.assertEqual(nodes['جزیره‌ی اول']['status'], 'completed')
self.assertEqual(nodes['جزیره‌ی دوم']['status'], 'unlocked')
# همه‌ی درس‌ها تمام شده‌اند: برای مرور، درسِ اول.
self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.a.id)
def test_progress_counts_only_lessons_that_can_be_played(self):
self._complete(self.a)
percent = self.client.get('/api/home').json()['stats']['progressPercent']
self.assertAlmostEqual(percent, 33.33, places=2) # ۱ از ۳ درسِ سوال‌دار
def test_new_content_is_offered_but_progress_never_goes_backwards(self):
self._complete(self.a)
self._complete(self.b)
self.assertEqual(self._nodes()['جزیره‌ی اول']['status'], 'completed')
self._question(self.empty) # حالا این درس هم بازی‌شدنی است
nodes = self._nodes()
# نقشه‌ی کش‌شده باطل شد: درسِ تازه پیشنهاد می‌شود…
self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.empty.id)
# …ولی جزیره‌ی تمام‌شده باز و جزیره‌ی بعدی قفل نمی‌شود.
self.assertEqual(nodes['جزیره‌ی اول']['status'], 'completed')
self.assertEqual(nodes['جزیره‌ی دوم']['status'], 'unlocked')

View File

@@ -40,7 +40,17 @@ class NodeQuizCompleteTests(TestCase):
Choice.objects.create(question=question, value='ب', is_correct=False)
return node
def _complete(self, node, correct=3, total=4, **headers):
def _answer_correctly(self, node, count):
"""مثلِ اپ: اول جواب‌ها ثبت می‌شوند، بعد پایانِ آزمون."""
questions = Question.objects.filter(lesson__node=node).order_by('id')[:count]
for question in questions:
choice = question.choices.get(is_correct=True)
self.client.post(f'/api/questions/{question.pk}/answer', {'selectedOptionId': choice.pk},
content_type='application/json', **self.auth)
def _complete(self, node, correct=3, total=4, answer=True, **headers):
if answer:
self._answer_correctly(node, correct)
return self.client.post(
f'/api/nodes/{node.pk}/quiz/complete',
{'correctCount': correct, 'totalQuestions': total},
@@ -90,6 +100,16 @@ class NodeQuizCompleteTests(TestCase):
self.assertEqual(again['coinsEarned'], 0)
self.assertEqual(UserStats.objects.get(user_id=self.user.id).xp, 40)
def test_answers_that_were_never_given_earn_nothing(self):
# پیش‌تر correctCount همان‌طور که کلاینت فرستاده بود پذیرفته می‌شد.
body = self._complete(self.first, correct=4, answer=False).json()
self.assertEqual(body['xpEarned'], 0)
self.assertEqual(body['stars'], 0)
self._answer_correctly(self.second, 2)
body = self._complete(self.second, correct=3, total=3, answer=False).json()
self.assertEqual(body['xpEarned'], 20) # فقط دو جوابِ ثبت‌شده
def test_same_idempotency_key_does_not_double_count(self):
self._complete(self.first, correct=4, HTTP_IDEMPOTENCY_KEY='k1')
body = self._complete(self.first, correct=4, HTTP_IDEMPOTENCY_KEY='k1').json()

View File

@@ -1,8 +1,9 @@
import logging
from django.conf import settings
from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.db.models import Count, F
from django.db.models import Count, F, Prefetch
from django.http import Http404
import random
@@ -21,7 +22,7 @@ from subscription.services import has_unlimited_hearts
from .services import (
grade_progress_percent, mark_node_completed_by_quiz, next_node_after,
quiz_completed_node_ids,
playable_lesson_ids, quiz_completed_node_ids, verified_correct_count,
)
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer,
@@ -120,7 +121,8 @@ _NODE_ICONS = ['Grid3x3', 'TrendingUp', 'LineChart', 'Sigma', 'Compass', 'Repeat
def _grade_tree(grade_id: int):
"""اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر.
None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد."""
key = f'content:tree:grade:{grade_id}:v{curriculum_version()}'
# tree2: هر node حالا `playable_ids` هم دارد؛ کلیدِ ساختارِ قبلی باطل شد.
key = f'content:tree2:grade:{grade_id}:v{curriculum_version()}'
cached = cache.get(key)
if cached is not None:
return None if cached is False else cached
@@ -131,7 +133,10 @@ def _grade_tree(grade_id: int):
units = (
Grade.objects.get(pk=grade_id)
.units.prefetch_related('nodes__lessons').all()
.units.prefetch_related(Prefetch(
'nodes__lessons',
queryset=Lesson.objects.annotate(question_total=Count('questions')),
)).all()
)
tree = []
position = 0 # شمارنده‌ی سراسریِ مبحث در این پایه، برای چرخشِ رنگ
@@ -144,6 +149,11 @@ def _grade_tree(grade_id: int):
'icon': node.icon or _NODE_ICONS[position % len(_NODE_ICONS)],
'color': node.color or _NODE_COLORS[position % len(_NODE_COLORS)],
'lesson_ids': [lsn.id for lsn in node.lessons.all()],
# فقط درس‌هایی که سوال دارند؛ درسِ بی‌سوال را نمی‌شود «تمام» کرد
# (اپ برایش دکمه‌ی شروع ندارد)، پس نباید جزیره را قفل نگه دارد.
'playable_ids': playable_lesson_ids(
(lsn.id, lsn.question_total) for lsn in node.lessons.all()
),
})
position += 1
tree.append({
@@ -248,6 +258,11 @@ class HomeView(APIView):
# همه‌ی lesson_idهای کامل‌شده‌ی این کاربر در همین گرید — یک کوئری،
# مستقل از تعدادِ node (رفعِ N+1).
all_lesson_ids = [lid for unit in tree for node in unit['nodes'] for lid in node['lesson_ids']]
# مخرجِ درصدِ پیشرفت: فقط درس‌های سوال‌دار (همان که جزیره را تمام می‌کند).
all_playable_ids = [
lid for unit in tree for node in unit['nodes']
for lid in (node.get('playable_ids') or node['lesson_ids'])
]
completed_ids = set()
if all_lesson_ids:
completed_ids = set(
@@ -256,8 +271,15 @@ class HomeView(APIView):
).values_list('lesson_id', flat=True)
)
quiz_done = quiz_completed_node_ids(
user_id, [int(node['id']) for unit in tree for node in unit['nodes']],
tree_node_ids = [int(node['id']) for unit in tree for node in unit['nodes']]
quiz_done = quiz_completed_node_ids(user_id, tree_node_ids)
# جزیره‌ای که یک بار تمام شده، تمام می‌ماند. بدونِ این، اضافه شدنِ اولین
# سوال به یک درسِ بی‌سوال (از ادمین) جزیره‌ی تمام‌شده را باز می‌کرد و
# جزیره‌ی بعدیِ دانش‌آموز را دوباره قفل می‌کرد — پیشرفت عقب می‌رفت.
finished = quiz_done | set(
NodeProgress.objects.filter(
user_id=user_id, node_id__in=tree_node_ids, status='completed',
).values_list('node_id', flat=True)
)
units_payload = []
@@ -265,9 +287,14 @@ class HomeView(APIView):
node_payload = []
unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه
for node in unit['nodes']:
playable = node.get('playable_ids') or node['lesson_ids']
node_status = _node_status(
node['lesson_ids'], completed_ids, unlock_next,
quiz_completed=int(node['id']) in quiz_done,
playable, completed_ids, unlock_next,
quiz_completed=int(node['id']) in finished,
)
next_lesson_id = next(
(lid for lid in playable if lid not in completed_ids),
playable[0] if playable else None,
)
if node_status != 'completed':
unlock_next = False
@@ -277,7 +304,11 @@ class HomeView(APIView):
'icon': node.get('icon', ''),
'color': node.get('color', ''),
'status': node_status,
'lessonIds': node['lesson_ids'],
'lessonIds': (
[next_lesson_id] + [lid for lid in node['lesson_ids'] if lid != next_lesson_id]
if next_lesson_id is not None else node['lesson_ids']
),
'nextLessonId': next_lesson_id,
})
units_payload.append({
'id': unit['id'],
@@ -290,14 +321,15 @@ class HomeView(APIView):
# همین داده‌ها: درس‌های تمام‌شده + همه‌ی درس‌های مبحث‌هایی که آزمونشان
# تمام شده. UserStats.progress_percent فقط در لحظه‌ی تکمیل به‌روز
# می‌شود و به پایه‌ی دیگر (?gradeId=) ربطی ندارد.
done_lessons = set(completed_ids)
playable_set = set(all_playable_ids)
done_lessons = set(completed_ids) & playable_set
for unit in tree:
for node in unit['nodes']:
if int(node['id']) in quiz_done:
done_lessons.update(node['lesson_ids'])
done_lessons.update(playable_set.intersection(node['lesson_ids']))
progress_percent = (
round(min(len(done_lessons) / len(all_lesson_ids), 1.0) * 100, 2)
if all_lesson_ids else 0
round(min(len(done_lessons) / len(playable_set), 1.0) * 100, 2)
if playable_set else 0
)
stats = UserStats.objects.filter(user_id=user_id).first()
@@ -410,6 +442,24 @@ class QuestionAnswerView(APIView):
# اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل
# خوانده/کم می‌شود تا lost update در پاسخ‌های هم‌زمان رخ ندهد. قید یکتاییِ
# سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را می‌گیرد.
# قلبِ صفر = نمی‌شود جواب داد (مثلِ دولینگو). بدونِ این، «قلبِ نامحدود»
# (تنها مزیتِ واقعیِ اشتراک) فقط در ظاهرِ اپ وجود داشت و با صدا زدنِ
# مستقیمِ API دور زده می‌شد. پشتِ HEARTS_ENFORCED است چون فرانتِ فعلی
# برای این ۴۰۳ هنوز صفحه‌ی «قلب‌ها تمام شد» ندارد.
if settings.HEARTS_ENFORCED and not has_unlimited_hearts(user_id):
hearts = UserHearts.objects.filter(user_id=user_id).first()
if hearts is not None:
hearts.apply_regen()
if hearts.current_hearts <= 0:
return Response(
{
'detail': 'قلب‌هایت تمام شده؛ کمی صبر کن تا دوباره پر شوند.',
'code': 'no_hearts',
'nextRefillAt': hearts.next_refill_at,
},
status=status.HTTP_403_FORBIDDEN,
)
try:
with transaction.atomic():
UserAnswer.objects.create(
@@ -501,6 +551,11 @@ class LessonCompleteView(APIView):
)
})
# امتیاز فقط برای جواب‌هایی که واقعاً ثبت شده‌اند. پیش‌تر عددِ کلاینت
# (فقط با سقفِ تعدادِ سوال‌ها) پذیرفته می‌شد، پس صدا زدنِ مستقیمِ همین
# endpoint برای هر درس، بی‌جواب دادن، امتیاز و جدولِ رتبه‌بندی را پر می‌کرد.
correct_count = min(correct_count, verified_correct_count(request.user.id, lesson_id=lesson.id))
# ratio همیشه بر اساس actual_question_count محاسبه می‌شود، نه totalQuestions ارسالی کلاینت.
ratio = correct_count / (actual_question_count or 1)
stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0
@@ -844,6 +899,9 @@ class NodeQuizCompleteView(APIView):
'correctCount': 'correctCount نمی‌تواند از totalQuestions بیشتر باشد.',
})
# مثلِ پایانِ درس: فقط جواب‌های درستِ ثبت‌شده در همین مبحث شمرده می‌شوند.
correct_count = min(correct_count, verified_correct_count(request.user.id, node_id=node_id))
ratio = correct_count / total_questions
stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0
user_id = request.user.id

View File

@@ -16,6 +16,9 @@ class RedeemRequestSerializer(serializers.Serializer):
class RedeemResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
discountAmount = serializers.IntegerField()
# «paid» یعنی کد کلِ مبلغ را پوشاند و خرید همین‌جا تمام شد؛ رفتن به درگاه لازم نیست.
orderStatus = serializers.CharField()
amount = serializers.IntegerField(help_text='مبلغِ سفارش بعد از تخفیف.')
class RedeemOfferCodeRequestSerializer(serializers.Serializer):

View File

@@ -146,6 +146,17 @@ class RedeemDiscountView(APIView):
if order_locked.status != 'pending':
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
# بعد از رفتن به درگاه، authorityِ زرین‌پال با مبلغِ قبلی گرفته شده و
# دوباره استفاده می‌شود؛ تخفیف در این لحظه یعنی کاربر مبلغِ قبلی را
# می‌پردازد ولی verify با مبلغِ جدید رد می‌شود — پول کم می‌شود و
# اشتراکی فعال نمی‌شود.
if order_locked.gateway_authority:
raise ValidationError({'code': 'کدِ تخفیف را پیش از رفتن به درگاهِ پرداخت وارد کنید.'})
# یک کد برای هر سفارش: کدهای درصدی روی هم جمع می‌شدند.
if Redemption.objects.filter(order_id=order_locked.id).exists():
raise ValidationError({'code': 'روی این سفارش قبلاً کدِ تخفیف اعمال شده است.'})
if discount.type == 'percent':
discount_amount = order_locked.amount * discount.value // 100
else:
@@ -160,11 +171,23 @@ class RedeemDiscountView(APIView):
# مبلغ سفارش واقعاً کم می‌شود تا مرحله‌ی پرداخت مبلغ تخفیف‌خورده را ببیند.
order_locked.amount = max(order_locked.amount - discount_amount, 0)
order_locked.save(update_fields=['amount'])
# سفارشِ صفرتومانی را هیچ درگاهی نمی‌پذیرد؛ کدِ ۱۰۰٪ یعنی خرید
# همین‌جا تمام است (همان مسیرِ پرداختِ واقعی: اشتراک، اعلان، پورسانت).
if order_locked.amount == 0:
from payment.services import apply_order_payment_result
apply_order_payment_result(
order=order_locked, gateway='discount',
gateway_ref_id=f'discount-{discount.id}-order-{order_locked.id}',
new_status='paid', event='discount_full',
)
except IntegrityError:
# fallback برای رقابت هم‌زمان روی قید یکتایی، حتی اگر قفل select_for_update رد شود.
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'})
data = {'success': True, 'discountAmount': discount_amount}
order.refresh_from_db(fields=['status', 'amount'])
data = {'success': True, 'discountAmount': discount_amount,
'orderStatus': order.status, 'amount': order.amount}
return Response(RedeemResponseSerializer(data).data)

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` توکن‌های ورود را امضا می‌کند.** بهترین حالت این است که
> اصلاً ست نشود تا خودِ تنظیمات یکی بسازد — آن‌وقت کلیدی وجود ندارد که لو

View File

@@ -5,7 +5,8 @@ from django.db.models import Q, Sum
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.crypto import get_random_string
from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.exceptions import NotFound, PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@@ -199,17 +200,43 @@ class FamilyLinkStatusView(APIView):
return Response(FamilyLinkStatusResponseSerializer(data).data)
_CHILD_PARAM = OpenApiParameter(
'childUserId', OpenApiTypes.INT, OpenApiParameter.QUERY, required=False,
description='برای والدِ چند فرزند؛ بدونِ آن، آخرین فرزندی که وصل شده.',
)
def _parent_link(request):
"""
پیوندِ پذیرفته‌شده‌ی این والد. پیش‌تر با get_object_or_404 روی
parent_user_id خوانده می‌شد و والدی که دو فرزند وصل کرده بود ۵۰۰ می‌گرفت
(MultipleObjectsReturned). حالا `?childUserId=` فرزند را انتخاب می‌کند و
بدونِ آن آخرین فرزندِ وصل‌شده برمی‌گردد — برای والدِ یک‌فرزندی همان قبلی.
"""
links = FamilyLink.objects.filter(parent_user_id=request.user.id, status='accepted')
child_id = request.query_params.get('childUserId')
if child_id:
try:
links = links.filter(child_user_id=int(child_id))
except (TypeError, ValueError):
raise NotFound
link = links.order_by('-accepted_at', '-id').first()
if link is None:
raise NotFound('فرزندی به این حساب وصل نیست.')
return link
class FamilySettingsView(APIView):
"""GET/PATCH /api/family/settings — تنظیمات کنترل والدین"""
permission_classes = [IsAuthenticated]
def _get_settings(self, request):
link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted')
link = _parent_link(request)
settings_obj, _ = ParentalSettings.objects.get_or_create(family_link=link)
return settings_obj
@extend_schema(responses=FamilySettingsResponseSerializer, tags=['Family (family)'])
@extend_schema(responses=FamilySettingsResponseSerializer, parameters=[_CHILD_PARAM], tags=['Family (family)'])
def get(self, request):
settings_obj = self._get_settings(request)
data = {
@@ -222,6 +249,7 @@ class FamilySettingsView(APIView):
@extend_schema(
request=FamilySettingsUpdateRequestSerializer,
responses=FamilySettingsUpdateResponseSerializer,
parameters=[_CHILD_PARAM],
tags=['Family (family)'],
)
def patch(self, request):
@@ -260,9 +288,9 @@ class FamilyWeeklyReportView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(responses=FamilyWeeklyReportResponseSerializer, tags=['Family (family)'])
@extend_schema(responses=FamilyWeeklyReportResponseSerializer, parameters=[_CHILD_PARAM], tags=['Family (family)'])
def get(self, request):
link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted')
link = _parent_link(request)
week_start = _week_start(timezone.now())
with transaction.atomic():

View File

View File

View File

@@ -0,0 +1,28 @@
"""
سفارش‌های pendingِ زرین‌پال را دوباره verify می‌کند.
همین کار برای هر کاربر خودکار هم انجام می‌شود (وقتی اپ وضعیتِ اشتراک یا
سفارش را می‌پرسد)؛ این فرمان برای وقتی است که بخواهید همه را یک‌جا ببینید:
python manage.py payment_reconcile --days 3
"""
from django.core.management.base import BaseCommand
from django.utils import timezone
from payment.models import Order
from payment.services import reconcile_pending_order
class Command(BaseCommand):
help = 'verifyِ دوباره‌ی سفارش‌های pendingِ زرین‌پال'
def add_arguments(self, parser):
parser.add_argument('--days', type=int, default=3)
def handle(self, *args, **opts):
since = timezone.now() - timezone.timedelta(days=opts['days'])
orders = list(Order.objects.filter(
status='pending', provider='gateway', created_at__gte=since,
).exclude(gateway_authority=''))
fixed = sum(1 for order in orders if reconcile_pending_order(order))
self.stdout.write(f'{len(orders)} سفارشِ pending بررسی شد؛ {fixed} سفارش paid شد.')

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-09-25 02:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payment', '0006_order_gateway_authority'),
]
operations = [
migrations.AddField(
model_name='order',
name='plan_id',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@@ -15,6 +15,10 @@ class Order(models.Model):
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
subscription_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Subscription
# پلنی که *این* سفارش برایش پرداخت می‌شود. پیش‌تر پلن فقط روی Subscription
# بود و با شروعِ یک خریدِ دیگر عوض می‌شد: پرداختِ سفارشِ ماهانه بعد از
# شروعِ خریدِ سالانه، اشتراکِ سالانه می‌داد (بازتولید شد، ۲۰۲۶-۰۹-۲۵).
plan_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Plan
amount = models.PositiveIntegerField()
currency = models.CharField(max_length=10, default='IRT')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')

View File

@@ -1,3 +1,5 @@
import secrets
from django.conf import settings
from rest_framework.permissions import BasePermission
@@ -14,18 +16,7 @@ class HasInternalServiceKey(BasePermission):
def has_permission(self, request, view):
provided = request.headers.get('X-Internal-Key', '')
return bool(provided) and provided == settings.INTERNAL_SERVICE_KEY
class HasWebhookSecret(BasePermission):
"""
برای POST /api/payment/webhook: باید از سمت درگاه پرداخت بیاد، نه یک
کلاینت دلخواه. تا زمان اتصال درگاه واقعی (که خودش امضای HMAC داره)،
یک shared-secret ساده از طریق هدر X-Webhook-Secret بررسی می‌شه.
"""
message = 'امضای وب‌هوک نامعتبر است.'
def has_permission(self, request, view):
provided = request.headers.get('X-Webhook-Secret', '')
return bool(provided) and provided == settings.PAYMENT_WEBHOOK_SECRET
expected = settings.INTERNAL_SERVICE_KEY
# مقایسه‌ی زمان‌ثابت: == در اولین نویسه‌ی متفاوت برمی‌گردد و از زمانِ پاسخ
# می‌شود کلید را نویسه‌به‌نویسه حدس زد.
return bool(provided) and bool(expected) and secrets.compare_digest(provided, expected)

View File

@@ -37,22 +37,6 @@ class CreateOrderResponseSerializer(serializers.Serializer):
provider = serializers.CharField()
class WebhookRequestSerializer(serializers.Serializer):
# کال‌بک از سمت درگاه پرداخت میاد، نه از اپ
orderId = serializers.CharField()
gateway = serializers.CharField(required=False, default='zarinpal')
gatewayRefId = serializers.CharField(required=False, allow_blank=True)
# ⚠️ اصلاح امنیتی: قبلاً status یک CharField آزاد بود و هر مقداری
# (حتی مقادیر پشتیبانی‌نشده/اشتباه) رو مستقیم روی Order ذخیره می‌کرد.
# حالا فقط دو وضعیت نهاییِ معتبرِ کال‌بک درگاه پذیرفته می‌شه؛ 'pending'
# وضعیت اولیه‌ی داخلیه و نباید از بیرون توسط webhook قابل‌تنظیم باشه.
status = serializers.ChoiceField(choices=['paid', 'failed'])
class WebhookResponseSerializer(serializers.Serializer):
received = serializers.BooleanField()
class ZarinpalCallbackResponseSerializer(serializers.Serializer):
"""فقط وقتی settings.ZARINPAL_REDIRECT_URL خالی باشه برگردونده می‌شه؛
وگرنه کاربر به همون URL ریدایرکت می‌شه، نه به این JSON."""

View File

@@ -25,13 +25,17 @@ class IdempotencyKeyConflict(APIException):
def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currency: str,
subscription_id: int | None) -> None:
subscription_id: int | None, plan_id: int | None = None) -> None:
if order.user_id != user_id:
raise IdempotencyKeyConflict(
'این Idempotency-Key متعلق به کاربر دیگری است و نمی‌تواند استفاده شود.'
)
if (order.amount != amount or order.currency != currency
or order.subscription_id != subscription_id):
# مبلغ فقط برای سفارشی که هنوز تخفیف نگرفته مقایسه می‌شود؛ بعد از کدِ
# تخفیف، مبلغِ سفارش عمداً کمتر از قیمتِ پلن است.
discounted = order.amount < amount and order.status == 'pending'
if ((order.amount != amount and not discounted) or order.currency != currency
or order.subscription_id != subscription_id
or (plan_id is not None and order.plan_id not in (None, plan_id))):
raise IdempotencyKeyConflict(
'این Idempotency-Key قبلاً با پارامترهای متفاوتی استفاده شده است.'
)
@@ -52,7 +56,8 @@ def call_payment_gateway(order: Order) -> str:
def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
subscription_id: int | None = None, idempotency_key: str | None = None,
provider: str = 'gateway', bazaar_product_sku: str = ''):
provider: str = 'gateway', bazaar_product_sku: str = '',
plan_id: int | None = None):
"""
ساخت سفارش با پشتیبانی از Idempotency-Key: اگر سفارشی با همین کلید قبلاً
ساخته شده باشد، همان رکورد برگردانده می‌شود (نه رکورد تکراری)، فقط اگر
@@ -65,7 +70,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
if existing is not None:
_ensure_idempotent_match(
existing, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id,
subscription_id=subscription_id, plan_id=plan_id,
)
payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing)
return existing, payment_url
@@ -75,6 +80,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
order = Order.objects.create(
user_id=user_id,
subscription_id=subscription_id,
plan_id=plan_id,
amount=amount,
currency=currency,
status='pending',
@@ -89,7 +95,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
raise
_ensure_idempotent_match(
order, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id,
subscription_id=subscription_id, plan_id=plan_id,
)
# برای provider='bazaar' هیچ paymentUrlی وجود ندارد: کلاینت اندروید مستقیماً
@@ -101,7 +107,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: str,
new_status: str, event: str) -> bool:
"""
منطق مشترکِ «نتیجه‌ی پرداخت رسید»، هم برای PaymentWebhookView و هم
منطق مشترکِ «نتیجه‌ی پرداخت رسید»، هم برای ZarinpalCallbackView و هم
BazaarVerifyPurchaseView استفاده می‌شود.
idempotent: اگر Transaction مشابه (order/status/gateway_ref_id) از قبل
@@ -167,7 +173,10 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
if newly_paid:
if order.subscription_id:
from subscription.services import activate_subscription
activate_subscription(order.subscription_id)
activate_subscription(order.subscription_id, plan_id=order.plan_id)
# پورسانتِ معرف فقط روی پرداختِ واقعی (نه ثبت‌نام، نه مهمان).
from affiliate.services import record_paid_order
record_paid_order(order)
from notifications.services import notify
notify(
@@ -178,3 +187,44 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
)
return newly_paid
def reconcile_pending_order(order: Order) -> bool:
"""
سفارشی که کاربر در درگاه پرداخت کرده ولی تأییدش نرسیده (مثلاً زرین‌پال
هنگامِ کال‌بک جواب نداد) را دوباره با verify می‌سنجد. پیش‌تر چنین سفارشی
برای همیشه pending می‌ماند: پولِ دانش‌آموز گرفته شده بود و اشتراکی در کار نبود.
فقط به سمتِ paid می‌برد، هرگز failed نمی‌کند: کاربری که هنوز در صفحه‌ی
درگاه است نباید سفارشش زیرِ پایش باطل شود. هر سفارش حداکثر دقیقه‌ای یک بار
سنجیده می‌شود. خروجی: True اگر همین فراخوانی سفارش را paid کرد.
"""
from django.core.cache import cache
from . import zarinpal
if order.provider != 'gateway' or order.status != 'pending' or not order.gateway_authority:
return False
if not cache.add(f'payment:reconcile:{order.id}', 1, 60):
return False
try:
result = zarinpal.verify_payment(
authority=order.gateway_authority, amount=order.amount, currency=order.currency,
)
except Exception: # noqa: BLE001 — درگاه در دسترس نیست؛ دفعه‌ی بعد
return False
if not result.is_valid:
return False
return apply_order_payment_result(
order=order, gateway='zarinpal', gateway_ref_id=result.ref_id,
new_status='paid', event='zarinpal_reconcile',
)
def reconcile_user_orders(user_id: int) -> None:
"""سفارش‌های pendingِ اخیرِ یک کاربر؛ از صفحه‌ی وضعیتِ اشتراک صدا زده می‌شود."""
since = timezone.now() - timezone.timedelta(days=2)
for order in Order.objects.filter(
user_id=user_id, status='pending', provider='gateway', created_at__gte=since,
).exclude(gateway_authority='')[:3]:
reconcile_pending_order(order)

View File

@@ -13,13 +13,13 @@ from accounts.models import User
from . import bazaar, zarinpal
from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction
from .permissions import HasInternalServiceKey, HasWebhookSecret
from .permissions import HasInternalServiceKey
from .services import call_payment_gateway
class SecretDefaultsFailClosedTests(TestCase):
"""
تست‌های متمرکز روی این فیکس: INTERNAL_SERVICE_KEY و PAYMENT_WEBHOOK_SECRET
تست‌های متمرکز روی این فیکس: INTERNAL_SERVICE_KEY
دیگه در پروداکشن (DEBUG=False) دیفالتِ قابل‌حدس‌زدن ندارن. اگه env تنظیم
نشده باشه، مقدار خالی می‌مونه و پرمیشن‌های مربوطه صراحتاً رد می‌کنن
(fail closed)، نه اینکه با یک مقدارِ شناخته‌شده قبول کنن.
@@ -35,15 +35,9 @@ class SecretDefaultsFailClosedTests(TestCase):
# settings.INTERNAL_SERVICE_KEY خودش خالیه، درخواست باید رد بشه.
self.assertFalse(HasInternalServiceKey().has_permission(request, None))
@override_settings(DEBUG=False, PAYMENT_WEBHOOK_SECRET='')
def test_production_missing_webhook_secret_rejects(self):
request = self.factory.post('/api/payment/webhook')
self.assertFalse(HasWebhookSecret().has_permission(request, None))
@override_settings(
DEBUG=False,
INTERNAL_SERVICE_KEY='prod-internal-key-xyz',
PAYMENT_WEBHOOK_SECRET='prod-webhook-secret-xyz',
)
def test_production_configured_secrets_accepts(self):
internal_request = self.factory.post(
@@ -51,11 +45,6 @@ class SecretDefaultsFailClosedTests(TestCase):
)
self.assertTrue(HasInternalServiceKey().has_permission(internal_request, None))
webhook_request = self.factory.post(
'/api/payment/webhook', HTTP_X_WEBHOOK_SECRET='prod-webhook-secret-xyz',
)
self.assertTrue(HasWebhookSecret().has_permission(webhook_request, None))
def test_debug_mode_local_fallback_still_valid(self):
# settings.py این مقادیر رو یک‌بار موقعِ import محاسبه می‌کنه، نه
# چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این
@@ -63,7 +52,7 @@ class SecretDefaultsFailClosedTests(TestCase):
from config import settings as settings_module
env_overrides = {'DJANGO_DEBUG': '1'}
env_removed = {'INTERNAL_SERVICE_KEY', 'PAYMENT_WEBHOOK_SECRET'}
env_removed = {'INTERNAL_SERVICE_KEY'}
clean_env = {
k: v for k, v in os.environ.items() if k not in env_removed
}
@@ -77,10 +66,6 @@ class SecretDefaultsFailClosedTests(TestCase):
settings_module.INTERNAL_SERVICE_KEY,
'dev-internal-service-key-change-me',
)
self.assertEqual(
settings_module.PAYMENT_WEBHOOK_SECRET,
'dev-webhook-secret-change-me',
)
finally:
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی
# بقیه‌ی تست‌ها اثر نذاره.

View File

@@ -0,0 +1,167 @@
"""
پول: ایرادهایی که بازبینیِ ۲۰۲۶-۰۹-۲۵ بازتولید کرد و حالا بسته شده‌اند.
هر تست همان سناریوی واقعی را اجرا می‌کند که پیش‌تر خراب بود.
"""
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from affiliate.models import AffiliateAccount, Conversion
from discount.models import DiscountCode
from payment.models import Order
from payment.services import apply_order_payment_result, reconcile_pending_order
from payment.zarinpal import ZarinpalVerifyResult
from subscription.models import Plan, Subscription
class _Base(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120007001', is_guest=False)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
self.month = Plan.objects.create(title='ماهانه', price=49000, duration_days=30)
self.year = Plan.objects.create(title='سالانه', price=299000, duration_days=365)
def tearDown(self):
cache.clear()
def buy(self, plan):
response = self.client.post('/api/subscription/purchase', {'planId': str(plan.id)}, format='json')
self.assertEqual(response.status_code, 200, response.content)
return Order.objects.get(pk=response.json()['orderId'])
def pay(self, order, ref='R'):
return apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref,
new_status='paid', event='test')
def subscription(self):
return Subscription.objects.get(user_id=self.user.id)
class CheckoutTests(_Base):
def test_paying_the_cheap_order_gives_the_cheap_plan(self):
cheap = self.buy(self.month)
self.buy(self.year) # شروعِ خریدِ دوم، بدونِ پرداخت
self.pay(cheap)
sub = self.subscription()
self.assertEqual(sub.plan, self.month)
days = (sub.expires_at - timezone.now()).days
self.assertIn(days, (29, 30))
def test_opening_checkout_does_not_take_away_an_active_subscription(self):
self.pay(self.buy(self.month), ref='R1')
self.buy(self.year)
self.assertEqual(self.subscription().status, 'active')
self.assertTrue(self.client.get('/api/subscription/status').json()['active'])
def test_renewing_early_keeps_the_remaining_days(self):
self.pay(self.buy(self.month), ref='R1')
first_expiry = self.subscription().expires_at
self.pay(self.buy(self.month), ref='R2')
self.assertEqual((self.subscription().expires_at - first_expiry).days, 30)
def test_an_expired_subscription_is_not_reported_active(self):
self.pay(self.buy(self.month), ref='R1')
Subscription.objects.filter(user_id=self.user.id).update(
expires_at=timezone.now() - timezone.timedelta(days=1))
self.assertFalse(self.client.get('/api/subscription/status').json()['active'])
def test_the_mark_paid_webhook_is_gone(self):
order = self.buy(self.month)
response = self.client.post('/api/payment/webhook', {'orderId': order.id, 'status': 'paid'},
format='json', HTTP_X_WEBHOOK_SECRET='anything')
self.assertEqual(response.status_code, 404)
class DiscountTests(_Base):
def setUp(self):
super().setUp()
self.half = DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
self.tenth = DiscountCode.objects.create(code='TENTH', type='percent', value=10, max_uses=10)
self.free = DiscountCode.objects.create(code='FREE', type='percent', value=100, max_uses=10)
def redeem(self, order, code):
return self.client.post('/api/discount/redeem', {'orderId': str(order.id), 'code': code}, format='json')
def test_one_code_per_order(self):
order = self.buy(self.month)
self.assertEqual(self.redeem(order, 'HALF').status_code, 200)
self.assertEqual(self.redeem(order, 'TENTH').status_code, 400)
order.refresh_from_db()
self.assertEqual(order.amount, 24500)
def test_no_discount_after_going_to_the_gateway(self):
order = self.buy(self.month)
Order.objects.filter(pk=order.pk).update(gateway_authority='A0000000000000000000000000000001')
self.assertEqual(self.redeem(order, 'HALF').status_code, 400)
order.refresh_from_db()
self.assertEqual(order.amount, 49000)
def test_a_full_discount_completes_the_purchase_without_a_gateway(self):
order = self.buy(self.month)
body = self.redeem(order, 'FREE').json()
self.assertEqual(body['orderStatus'], 'paid')
self.assertEqual(body['amount'], 0)
self.assertEqual(self.subscription().status, 'active')
class ReconcileTests(_Base):
def test_a_payment_whose_callback_was_lost_is_picked_up(self):
order = self.buy(self.month)
Order.objects.filter(pk=order.pk).update(gateway_authority='A1')
order.refresh_from_db()
with patch('payment.zarinpal.verify_payment',
return_value=ZarinpalVerifyResult(is_valid=True, ref_id='REF9')):
self.assertTrue(self.client.get('/api/subscription/status').json()['active'])
order.refresh_from_db()
self.assertEqual(order.status, 'paid')
def test_an_unpaid_order_is_left_pending_not_failed(self):
order = self.buy(self.month)
Order.objects.filter(pk=order.pk).update(gateway_authority='A2')
order.refresh_from_db()
with patch('payment.zarinpal.verify_payment', return_value=ZarinpalVerifyResult(is_valid=False)):
self.assertFalse(reconcile_pending_order(order))
order.refresh_from_db()
self.assertEqual(order.status, 'pending')
class AffiliateTests(_Base):
def setUp(self):
super().setUp()
self.referrer = User.objects.create(phone='09120007002', is_guest=False)
self.account = AffiliateAccount.objects.create(user_id=self.referrer.id, referral_code='SARA10',
commission_rate=0.1)
def test_commission_is_recorded_on_the_first_real_payment(self):
self.assertEqual(self.client.post('/api/affiliate/track', {'referralCode': 'SARA10'},
format='json').status_code, 200)
self.pay(self.buy(self.month), ref='R1')
conversion = Conversion.objects.get(referred_user_id=self.user.id)
self.assertEqual(conversion.status, 'paid')
self.assertEqual(conversion.commission_amount, 4900)
self.pay(self.buy(self.month), ref='R2') # فقط اولین پرداخت
self.assertEqual(Conversion.objects.get(pk=conversion.pk).commission_amount, 4900)
referrer = APIClient()
referrer.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.referrer.id)}')
me = referrer.get('/api/affiliate/me').json()
self.assertEqual((me['referredCount'], me['paidCount'], me['totalCommission']), (1, 1, 4900))
def test_guests_take_no_part(self):
guest = User.objects.create(device_id='dev-aff', is_guest=True)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(guest.id)}')
self.assertEqual(client.post('/api/affiliate/track', {'referralCode': 'SARA10'},
format='json').status_code, 403)
self.assertEqual(client.get('/api/affiliate/me').status_code, 403)
self.assertFalse(Conversion.objects.exists())

View File

@@ -5,13 +5,11 @@ from .views import (
CreateOrderView,
OrderDetailView,
PaymentRedirectView,
PaymentWebhookView,
ZarinpalCallbackView,
)
urlpatterns = [
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
path('payment/webhook', PaymentWebhookView.as_view(), name='payment-webhook'),
path('payment/zarinpal/callback', ZarinpalCallbackView.as_view(), name='payment-zarinpal-callback'),
path('payment/bazaar/verify', BazaarVerifyPurchaseView.as_view(), name='payment-bazaar-verify'),
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),

View File

@@ -13,18 +13,16 @@ from rest_framework.views import APIView
from . import bazaar, zarinpal
from .models import Order
from .permissions import HasInternalServiceKey, HasWebhookSecret
from .permissions import HasInternalServiceKey
from .serializers import (
BazaarVerifyRequestSerializer,
BazaarVerifyResponseSerializer,
CreateOrderRequestSerializer,
CreateOrderResponseSerializer,
OrderDetailResponseSerializer,
WebhookRequestSerializer,
WebhookResponseSerializer,
ZarinpalCallbackResponseSerializer,
)
from .services import apply_order_payment_result, create_order
from .services import apply_order_payment_result, create_order, reconcile_pending_order
def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
@@ -115,64 +113,6 @@ class CreateOrderView(APIView):
return Response(CreateOrderResponseSerializer(data).data)
class PaymentWebhookView(APIView):
"""
POST /api/payment/webhook — وب‌هوکِ عمومیِ shared-secret (نه مخصوصِ زرین‌پال).
جریانِ واقعیِ زرین‌پال از PaymentRedirectView/ZarinpalCallbackView رد
می‌شه (زرین‌پال خودش webhookِ سرور-به-سرور نمی‌زنه، فقط مرورگرِ کاربر رو
ریدایرکت می‌کنه). این endpoint برای تاییدِ دستی/تستی یا درگاه‌های دیگه‌ای
که واقعاً webhook POST می‌زنن نگه داشته شده؛ محافظت‌شده با
HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک
orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه.
همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/
notify تکراری نمی‌سازه.
"""
permission_classes = [HasWebhookSecret]
# این endpoint فقط با مقایسه‌ی هدرِ ثابتِ X-Webhook-Secret محافظت می‌شه،
# پس throttle لازمه تا حدسِ سکرت (یا fuzzِ orderId) محدود بمونه. چون
# caller خودِ درگاهه نه یک کاربرِ اپ، از ScopedRateThrottleِ IP-محور
# استفاده شده، نه الگویِ GuestAware*Throttle که نیازمندِ request.user است.
throttle_scope = 'payment_webhook'
throttle_classes = [ScopedRateThrottle]
# ⚠️ افزوده شده: مستندسازی هدر X-Webhook-Secret که قبلاً در Swagger UI
# قابل تنظیم/تست نبود.
@extend_schema(
request=WebhookRequestSerializer,
responses=WebhookResponseSerializer,
parameters=[
OpenApiParameter(
'X-Webhook-Secret', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True,
description='کلید مخفیِ وب‌هوک درگاه پرداخت (settings.PAYMENT_WEBHOOK_SECRET).',
),
],
tags=['Payment (payment)'],
)
def post(self, request):
serializer = WebhookRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
gateway_ref_id = v.get('gatewayRefId', '')
with transaction.atomic():
order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId'])
# ⚠️ اصلاح امنیتی/صحت داده (قبلی): هر بار فراخوانی webhook (حتی
# تکراری از سمت درگاه) یک Transaction جدید می‌ساخت. حالا این
# idempotency + اثرات جانبی (فعال‌سازی اشتراک/notify/statuslog)
# داخل apply_order_payment_result مشترکه (هم اینجا هم بازار).
apply_order_payment_result(
order=order,
gateway=v.get('gateway') or 'zarinpal',
gateway_ref_id=gateway_ref_id,
new_status=v['status'],
event='gateway_webhook',
)
return Response(WebhookResponseSerializer({'received': True}).data)
class PaymentRedirectView(APIView):
"""
GET /api/payment/orders/{id}/pay — همان paymentUrlی که
@@ -226,8 +166,7 @@ class ZarinpalCallbackView(APIView):
GET /api/payment/zarinpal/callback — کال‌بکی که زرین‌پال بعدِ پرداخت،
مرورگرِ کاربر رو بهش ریدایرکت می‌کنه (Authority, Status در query string).
برخلافِ PaymentWebhookView (یک POSTِ سرور-به-سرور با سکرتِ مشترک)،
اینجا caller خودِ مرورگرِ کاربره، نه زرین‌پال مستقیماً — پس هیچ
caller خودِ مرورگرِ کاربره، نه زرین‌پال مستقیماً — پس هیچ
هدر/سکرتی برای اعتمادکردن نیست. امنیت از verify_payment میاد (یک
فراخوانیِ سرور-به-سرور با Authority+amount): حتی اگه کاربر Status=OK
رو دستی توی URL جعل کنه، بدونِ تاییدِ واقعیِ زرین‌پال چیزی paid نمی‌شه.
@@ -350,6 +289,8 @@ class OrderDetailView(APIView):
@extend_schema(responses=OrderDetailResponseSerializer, tags=['Payment (payment)'])
def get(self, request, order_id):
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
if reconcile_pending_order(order):
order.refresh_from_db()
data = {
'orderId': str(order.id),
'status': order.status,

View File

@@ -0,0 +1,38 @@
"""
سه‌یارِ اپ (سیگما، دلتا، پی) به‌عنوانِ کاراکترهای واقعیِ کاتالوگ.
فرانت از ۲۰۲۶-۰۹-۲۴ صفحه‌ی «همراهت رو انتخاب کن» دارد و فقط slugهای
sigma/delta/pi را با طرحِ انیمیشن‌دارِ خودش نشان می‌دهد؛ هر slugِ دیگری کارتِ
عمومی می‌گیرد. تا امروز کاتالوگ روی سرور یا خالی بود یا فقط ردیف‌های [SEED]
داشت. این‌ها با get_or_create ساخته می‌شوند، پس اگر بعداً از ادمین
ویرایش شوند (نام، امتیازِ باز شدن)، اجرای دوباره چیزی را برنمی‌گرداند.
امتیازِ باز شدن تصمیمِ محصولی است و از ادمین قابلِ تغییر است؛ ۱۰ امتیاز برای
هر جوابِ درست، یعنی دلتا بعد از ~۱۰ و پی بعد از ~۳۰ جوابِ درست.
"""
from django.db import migrations
SEYAR = [
('sigma', 'سیگما', 0, 1),
('delta', 'دلتا', 100, 2),
('pi', 'پی', 300, 3),
]
def create(apps, schema_editor):
Character = apps.get_model('progress', 'Character')
for slug, name, unlock_xp, order in SEYAR:
Character.objects.get_or_create(
slug=slug,
defaults={'name': name, 'icon': slug, 'unlock_xp': unlock_xp, 'order': order},
)
def remove(apps, schema_editor):
apps.get_model('progress', 'Character').objects.filter(
slug__in=[s for s, *_ in SEYAR]).delete()
class Migration(migrations.Migration):
dependencies = [('progress', '0003_userhearts_full_refill_at')]
operations = [migrations.RunPython(create, remove)]

View File

@@ -78,6 +78,12 @@ class ActivityFeedsStreakAndLeaderboardTests(TestCase):
self.assertEqual(len(calendar['activeDays']), 1)
def test_a_finished_island_shows_up_on_the_leaderboard(self):
for question in Question.objects.filter(lesson__node=self.node).order_by('id')[:3]:
self.client.post(
f'/api/questions/{question.pk}/answer',
{'selectedOptionId': question.choices.get(is_correct=True).pk},
content_type='application/json', **self.auth,
)
self.client.post(
f'/api/nodes/{self.node.pk}/quiz/complete',
{'correctCount': 3, 'totalQuestions': 4},

View File

@@ -0,0 +1,341 @@
"""
مقایسه‌ی مدل‌های چت برای دستیار («از پی بپرس») با عدد، نه با حدس.
چرا جدا از سنجشِ استخراجِ PDF: آنجا مدل صفحه‌ی کتاب را می‌خواند (بینایی)،
اینجا باید مسئله را درست حل کند و مرحله‌به‌مرحله توضیح دهد. مدلی که در یکی
بهترین است لزوماً در دیگری نیست — gpt-4o-mini در استخراج مختصات جعل می‌کرد و
در چت هم مشتقِ x³ − 6x² + 9x را غلط گرفت.
روش:
۱. از هر کتاب چند مسئله‌ی بی‌شکل برمی‌دارد که `rag_verify_solutions` جوابِ
کتاب را تأیید کرده (`agrees`) و جوابِ مستقلش (`checked_answer`) ثبت است؛
همان جواب کلیدِ تصحیح است.
۲. هر مسئله را از همان مسیرِ واقعیِ دستیار می‌گذراند: retrieval روی کلِ کتابِ
آن پایه (مثلِ صفحه‌ی «از پی بپرس») + همان پرامپتِ تولید. خودِ همان تکه از
context حذف می‌شود تا مدل جواب را از کتاب رونویسی نکند.
۳. یک مدلِ داورِ ثابت فقط جوابِ نهایی را با کلید مقایسه می‌کند.
۴. هزینه‌ی واقعی از `/dashboard/billing/usage` قبل و بعد از هر مدل خوانده
می‌شود — قیمتِ GapGPT با جدولِ رسمی یکی نیست (docs/extraction-playbook.md).
# رایگان: فقط نمونه را نشان می‌دهد
python manage.py rag_model_benchmark --dry-run
python manage.py rag_model_benchmark --models gpt-4o-mini gpt-4.1-mini o4-mini \\
--per-grade 6 --json /tmp/bench.json
⚠️ GapGPT فقط از داخلِ ایران جواب می‌دهد؛ این را روی سرور اجرا کنید.
"""
import hashlib
import json
import re
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from django.core.management.base import BaseCommand, CommandError
from rag.management.commands.rag_verify_solutions import (
AccountLimitReached, is_account_limit, needs_figure,
)
from rag.models import LessonChunk
from rag.services import clients
from rag.services.config import rag_is_configured
from rag.services.generation import completion_kwargs, generate_answer, grade_tier
from rag.services.retrieval import retrieve_context
DEFAULT_MODELS = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'o4-mini']
DEFAULT_JUDGE = 'gpt-4.1'
JUDGE_PROMPT = """یک مسئله‌ی ریاضیِ مدرسه، جوابِ درستِ آن (کلید)، و پاسخِ یک معلم به دانش‌آموز
را می‌بینی. فقط بگو جوابِ نهاییِ معلم با کلید یکی است یا نه.
قواعد:
- شکلِ نوشتن مهم نیست: 0.5 = ۱/۲ = نیم؛ «۳/۵» در کلید ممکن است ممیزِ اعشار باشد (۳٫۵).
- ترتیب، واحدِ هم‌ارز، و گِرد کردنِ معقول خطا نیست.
- اگر کلید چند بخش دارد، همه‌ی بخش‌ها باید درست باشند.
- اگر معلم جوابِ نهایی نداده یا چند جوابِ متناقض داده، "wrong".
- اگر خودِ کلید با مسئله نمی‌خواند یا مبهم است، "unclear".
مسئله:
{problem}
کلید:
{key}
پاسخِ معلم:
{answer}
فقط JSON: {{"verdict": "correct|wrong|unclear", "why": "حداکثر ۸۰ نویسه"}}"""
# نشانه‌ی LaTeX/markdown در خروجیِ *خامِ* مدل (پیش از تورِ ایمنی)
_STEP_LINE = re.compile(r'^\s*([0-9۰-۹]+[)\.)]|مرحله)', re.M)
_KEY_NOT_AN_ANSWER = re.compile(r'کتاب|اثبات|درست است|صحیح است|مطابق|توضیح')
def _gradable(chunk) -> bool:
"""
فقط مسئله‌ای که جوابِ مشخص و کوتاه دارد (عدد، عبارت، مجموعه). کلیدهایی
مثلِ «راه‌حلِ کتاب مطابقِ تعریف است» یا اثبات‌ها را نمی‌شود با کلید سنجید.
"""
key = (chunk.checked_answer or '').strip()
return (
0 < len(key) <= 80
and bool(re.search(r'[0-9۰-۹]', key))
and not _KEY_NOT_AN_ANSWER.search(key)
and not chunk.problem.strip().startswith(('اثبات', 'ثابت کنید', 'نشان دهید'))
)
def _stable_order(chunk_id: int, seed: str) -> str:
return hashlib.sha1(f'{seed}:{chunk_id}'.encode()).hexdigest()
def _grade_of(item) -> str:
return (item['grade'] or item['collection'].split()[-1]).strip()
def _usage_cents() -> float | None:
"""مصرفِ تجمعیِ حساب به سنت، یا None اگر provider این endpoint را ندارد."""
import httpx
from django.conf import settings
try:
response = httpx.get(
f'{settings.OPENAI_BASE_URL.rstrip("/")}/dashboard/billing/usage',
headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, timeout=20,
)
data = response.json()
return float(data['total_usage']) if 'total_usage' in data else None
except Exception: # noqa: BLE001
return None
class Command(BaseCommand):
help = 'مقایسه‌ی دقت/هزینه/سرعتِ مدل‌های چت روی مسئله‌های تأییدشده‌ی کتاب'
def add_arguments(self, parser):
parser.add_argument('--models', nargs='+', default=DEFAULT_MODELS)
parser.add_argument('--judge', default=DEFAULT_JUDGE)
parser.add_argument('--per-grade', type=int, default=6)
parser.add_argument('--collections', nargs='*', default=None,
help='پیش‌فرض: همه‌ی کتاب‌های ریاضی/حسابان')
parser.add_argument('--seed', default='bench-v1')
parser.add_argument('--workers', type=int, default=4)
parser.add_argument('--no-context', action='store_true',
help='بدونِ retrieval — فقط توانِ ریاضیِ خودِ مدل')
parser.add_argument('--json', dest='json_path', default=None)
parser.add_argument('--dry-run', action='store_true')
parser.add_argument('--resume', action='store_true',
help='مدل‌هایی که در فایلِ --json تمام شده‌اند دوباره اجرا نشوند')
# LessonChunk (و کلیدِ checked_answer) فقط در دیتابیسِ محلی است، ولی GapGPT
# فقط از ایران جواب می‌دهد: نمونه را اینجا بنویس، روی سرور بخوان.
parser.add_argument('--export-sample', default=None, metavar='PATH')
parser.add_argument('--sample-file', default=None, metavar='PATH')
# ------------------------------------------------------------------ sample
def _sample(self, per_grade, collections, seed):
qs = LessonChunk.objects.filter(solution_verdict='agrees').exclude(
checked_answer='').exclude(problem='')
if collections:
qs = qs.filter(collection__in=collections)
else:
qs = qs.filter(collection__regex=r'^(ریاضی|حسابان) ')
by_collection = defaultdict(list)
for chunk in qs.only('id', 'problem', 'checked_answer', 'collection', 'grade_title',
'subject', 'chapter'):
if needs_figure(chunk.problem) or not _gradable(chunk):
continue
by_collection[chunk.collection].append(chunk)
items = []
for collection in sorted(by_collection):
chunks = sorted(by_collection[collection], key=lambda c: _stable_order(c.id, seed))
items.extend({
'id': c.id, 'problem': c.problem, 'key': c.checked_answer,
'collection': c.collection, 'grade': c.grade_title,
} for c in chunks[:per_grade])
return items
# ----------------------------------------------------------------- context
def _context(self, item):
subject = item['collection'].rsplit(' ', 1)[0]
hits, _meta = retrieve_context(item['problem'], subject=subject, grade_title=_grade_of(item))
needle = ' '.join(item['problem'].split())[:60]
# همان تکه (و هر تکه‌ای که عینِ این مسئله را دارد) بیرون: وگرنه مدل
# فقط راه‌حلِ کتاب را رونویسی می‌کند و سنجه بی‌معنا می‌شود.
return [h for h in hits if needle not in ' '.join(h.get('summary', '').split())]
# ------------------------------------------------------------------ judge
def _judge(self, judge_model, item, answer):
client = clients.get_openai_client()
prompt = JUDGE_PROMPT.format(problem=item['problem'], key=item['key'],
answer=answer[-2500:])
for attempt in range(3):
try:
completion = client.chat.completions.create(
model=judge_model, messages=[{'role': 'user', 'content': prompt}],
response_format={'type': 'json_object'}, **completion_kwargs(judge_model),
)
data = json.loads(completion.choices[0].message.content or '{}')
verdict = data.get('verdict')
if verdict in ('correct', 'wrong', 'unclear'):
return verdict, str(data.get('why') or '')[:120]
except Exception as exc: # noqa: BLE001
if is_account_limit(exc):
raise AccountLimitReached(str(exc)[:160]) from exc
time.sleep(2 * (attempt + 1))
return 'unclear', 'داوری شکست خورد'
# ------------------------------------------------------------------ handle
def handle(self, *args, **opts):
if opts['sample_file']:
with open(opts['sample_file'], encoding='utf-8') as fh:
items = json.load(fh)
else:
items = self._sample(opts['per_grade'], opts['collections'], opts['seed'])
if opts['export_sample']:
with open(opts['export_sample'], 'w', encoding='utf-8') as fh:
json.dump(items, fh, ensure_ascii=False, indent=1)
self.stdout.write(f'{len(items)} مسئله در {opts["export_sample"]} نوشته شد.')
return
if not items:
raise CommandError('هیچ مسئله‌ی تأییدشده‌ای پیدا نشد.')
per_collection = defaultdict(int)
for item in items:
per_collection[item['collection']] += 1
self.stdout.write(f'{len(items)} مسئله از {len(per_collection)} کتاب:')
for collection, n in sorted(per_collection.items()):
self.stdout.write(f' {collection:18s} {n}')
calls = len(items) * len(opts['models'])
self.stdout.write(f'{calls} پاسخ + {calls} داوری با {opts["judge"]}')
if opts['dry_run']:
for item in items[:3]:
self.stdout.write(f'\n— {item["collection"]}: {item["problem"][:160]}\n کلید: {item["key"][:80]}')
return
if not rag_is_configured():
raise CommandError('RAG پیکربندی نشده (OPENAI_API_KEY/QDRANT_API_KEY).')
# context یک بار برای همه‌ی مدل‌ها: مقایسه منصفانه و embedding ارزان‌تر.
contexts = {}
if not opts['no_context']:
self.stdout.write('retrieval …')
try:
with ThreadPoolExecutor(opts['workers']) as pool:
for item, ctx in zip(items, pool.map(self._context, items)):
contexts[item['id']] = ctx
except Exception as exc: # noqa: BLE001
if is_account_limit(exc):
raise CommandError(f'⛔ اعتبارِ حساب تمام شده است: {str(exc)[:160]}') from exc
raise
# نتیجه‌ی هر مدل همان لحظه روی دیسک می‌رود. اجرای اول وسطِ کار با تمام
# شدنِ اعتبار مرد و چون فقط آخرِ کار ذخیره می‌کرد، پاسخ‌های چهار مدلِ
# پرداخت‌شده از دست رفت. با --resume مدل‌های تمام‌شده دوباره اجرا نمی‌شوند.
results, summary = [], {}
if opts['resume'] and opts['json_path']:
try:
with open(opts['json_path'], encoding='utf-8') as fh:
saved = json.load(fh)
summary = saved.get('summary', {})
results = [r for r in saved.get('results', []) if r['model'] in summary]
self.stdout.write(f'ادامه: {len(summary)} مدل از قبل — {", ".join(summary)}')
except FileNotFoundError:
pass
chunks_by_id = {item['id']: item for item in items}
try:
for model in opts['models']:
if model in summary:
continue
self._run_model(model, items, contexts, chunks_by_id, opts, results, summary)
self._save(opts['json_path'], summary, results)
except AccountLimitReached as exc:
self._save(opts['json_path'], summary, results)
self.stderr.write(self.style.ERROR(
f'\n⛔ اعتبارِ حساب تمام شد ({exc}). نتیجه‌ی مدل‌های تمام‌شده ذخیره شد؛ '
'بعد از شارژ با همان دستور و --resume ادامه بدهید.'))
self._report([m for m in opts['models'] if m in summary], results, summary)
def _save(self, path, summary, results):
if not path:
return
with open(path, 'w', encoding='utf-8') as fh:
json.dump({'summary': summary, 'results': results}, fh, ensure_ascii=False, indent=1)
def _run_model(self, model, items, contexts, chunks_by_id, opts, results, summary):
self.stdout.write(f'\n▶ {model}')
before = _usage_cents()
started = time.monotonic()
def run(item):
t0 = time.monotonic()
try:
answer, _f, meta = generate_answer(
item['problem'], contexts.get(item['id']) or None, _grade_of(item), model=model,
)
return item, answer, meta, time.monotonic() - t0, None
except Exception as exc: # noqa: BLE001
if is_account_limit(exc):
raise AccountLimitReached(str(exc)[:160]) from exc
return item, '', {}, time.monotonic() - t0, str(exc)[:200]
with ThreadPoolExecutor(opts['workers']) as pool:
answered = list(pool.map(run, items))
after = _usage_cents()
wall = time.monotonic() - started
cost = (after - before) if before is not None and after is not None else None
rows = []
for item, answer, meta, seconds, error in answered:
rows.append({
'model': model, 'chunkId': item['id'], 'collection': item['collection'],
'tier': grade_tier(_grade_of(item)), 'problem': item['problem'],
'key': item['key'], 'answer': answer, 'seconds': round(seconds, 1),
'sanitized': bool(meta.get('sanitized')),
'completionTokens': meta.get('completionTokens', 0),
'hasFinal': '✅' in answer, 'steps': len(_STEP_LINE.findall(answer)),
'error': error, 'verdict': 'error', 'why': '',
})
errors = sum(1 for r in rows if r['error'])
self.stdout.write(
f' {len(rows) - errors} پاسخ، {errors} خطا، '
f'{"هزینه: %.2f سنت" % cost if cost is not None else "هزینه: نامعلوم"}، {wall:.0f}s'
)
todo = [r for r in rows if r['answer']]
with ThreadPoolExecutor(opts['workers']) as pool:
verdicts = list(pool.map(
lambda r: self._judge(opts['judge'], chunks_by_id[r['chunkId']], r['answer']), todo))
for r, (verdict, why) in zip(todo, verdicts):
r['verdict'], r['why'] = verdict, why
results.extend(rows)
summary[model] = {'costCents': cost, 'wallSeconds': round(wall, 1)}
def _report(self, models, results, summary):
tiers = ['primary', 'middle', 'upper']
header = f'{"مدل":18s} {"درست":>7s} {"دبستان":>7s} {"متوسطه۱":>7s} {"متوسطه۲":>7s} ' \
f'{"نامعلوم":>6s} {"سنت/پاسخ":>8s} {"ثانیه":>6s} {"LaTeX":>6s} {"✅":>5s}'
self.stdout.write('\n' + header)
for model in models:
rows = [r for r in results if r['model'] == model]
judged = [r for r in rows if r['verdict'] in ('correct', 'wrong')]
def pct(subset):
graded = [r for r in subset if r['verdict'] in ('correct', 'wrong')]
if not graded:
return '—'
return f'{100 * sum(r["verdict"] == "correct" for r in graded) / len(graded):.0f}%'
cost = summary[model]['costCents']
per = f'{cost / len(rows):.3f}' if cost is not None and rows else '?'
secs = sum(r['seconds'] for r in rows) / max(len(rows), 1)
latex = sum(r['sanitized'] for r in rows) / max(len(rows), 1)
final = sum(r['hasFinal'] for r in rows) / max(len(rows), 1)
self.stdout.write(
f'{model:18s} {pct(judged):>7s} '
+ ' '.join(f'{pct([r for r in rows if r["tier"] == t]):>7s}' for t in tiers)
+ f' {sum(r["verdict"] == "unclear" for r in rows):>6d} {per:>8s} {secs:>6.1f}'
f' {100 * latex:>5.0f}% {100 * final:>4.0f}%'
)

View File

@@ -64,22 +64,9 @@ class AccountLimitReached(Exception):
"""حسابِ سرویس جواب نمی‌دهد؛ ادامه دادن بی‌معناست."""
def is_account_limit(exc) -> bool:
"""
آیا این خطا یعنی «حسابِ سرویس دیگر جواب نمی‌دهد» یا فقط کندیِ لحظه‌ای؟
دو شکلِ واقعی که دیدیم و هر دو باید اجرا را متوقف کنند:
429 initial_usage_limit_exceeded — سقفِ حسابِ تازه، ساعت‌ها
403 insufficient user quota — اعتبار تمام شده
نکته: متنِ دومی «insufficient user quota» است با فاصله، نه زیرخط. محافظِ
اولِ ما دنبالِ `insufficient_quota` می‌گشت و نمی‌گرفتش، و اجرا ۷۷۰ فراخوانیِ
محکوم‌به‌شکست زد به‌جای اینکه سرِ اولی بایستد.
"""
text = str(exc).lower()
if 'usage_limit' in text or 'quota' in text:
return True
return '403' in text and 'insufficient' in text
# خودِ تشخیص در rag.services.clients است چون دستیارِ زنده هم لازمش دارد
# (/api/health باید بگوید «اعتبار تمام شده»، نه فقط «کلید تنظیم شده»).
from rag.services.clients import is_account_limit # noqa: E402,F401
# مسئله‌ای که به شکلِ کتاب ارجاع می‌دهد و محتوایش را ننوشته، بدونِ تصویر

View File

@@ -35,10 +35,24 @@ def make_key(collection, filter_value, question: str, grade_title: str = '') ->
یعنی پاسخی که برای دانش‌آموزِ نهم ساخته شده عیناً به دانش‌آموزِ سوم
برمی‌گشت.
"""
raw = f'{collection}|{filter_value}|{(grade_title or "").strip()}|{_norm(question)}'
from .generation import PROMPT_VERSION
# نسخه‌ی پرامپت هم جزءِ کلید است: بعد از عوض شدنِ پرامپت، جوابِ نسخه‌ی
# قبلی (مثلاً با LaTeX) نباید تا پایانِ TTL دوباره سرو شود.
raw = (f'{PROMPT_VERSION}|{collection}|{filter_value}|'
f'{(grade_title or "").strip()}|{_norm(question)}')
return _PREFIX + hashlib.sha1(raw.encode('utf-8')).hexdigest()
def cacheable(history) -> bool:
"""
فقط پیامِ اولِ هر گفتگو کش می‌شود. «نفهمیدم» یا «یه مثال دیگه بزن» معنایش
به گفتگوی قبلی بسته است؛ پیش‌تر کلید فقط متنِ سوال بود و هر «نفهمیدم» در
همان کتاب، جوابِ کش‌شده‌ی گفتگوی یک نفرِ دیگر را می‌گرفت.
"""
return not history
def get(key: str) -> dict | None:
if not enabled():
return None

View File

@@ -50,6 +50,24 @@ def get_qdrant_client():
)
def is_account_limit(exc) -> bool:
"""
آیا این خطا یعنی «حسابِ سرویس دیگر جواب نمی‌دهد» یا فقط کندیِ لحظه‌ای؟
دو شکلِ واقعی که دیدیم و هر دو باید اجرا را متوقف کنند:
429 initial_usage_limit_exceeded — سقفِ حسابِ تازه، ساعت‌ها
403 insufficient user quota — اعتبار تمام شده
نکته: متنِ دومی «insufficient user quota» است با فاصله، نه زیرخط. محافظِ
اولِ ما دنبالِ `insufficient_quota` می‌گشت و نمی‌گرفتش، و اجرا ۷۷۰ فراخوانیِ
محکوم‌به‌شکست زد به‌جای اینکه سرِ اولی بایستد.
"""
text = str(exc).lower()
if 'usage_limit' in text or 'quota' in text:
return True
return '403' in text and 'insufficient' in text
def reset_clients_cache():
"""برای تست‌ها / بعد از تغییرِ settings در زمانِ اجرا."""
get_openai_client.cache_clear()

View File

@@ -1,10 +1,21 @@
"""
مرحله‌ی generation: پرسش + context بازیابی‌شده → پاسخِ فارسی + چند پیشنهادِ
ادامه.
مرحله‌ی generation: پرسش + context بازیابی‌شده (+ چند پیامِ قبلیِ همان گفتگو)
→ پاسخِ فارسی + چند پیشنهادِ ادامه.
اگه context خالی باشه، مدل به‌عنوانِ معلمِ عمومی جواب می‌ده (بدونِ ادعای
استناد به کتاب) — این حالت وقتی پیش می‌آد که subject/lessonNumber ارسال
نشده یا اون درس هنوز در Qdrant نیست.
اگه context خالی باشه، مدل به‌عنوانِ معلمِ همان پایه جواب می‌ده (بدونِ ادعای
استناد به کتاب) — این حالت وقتی پیش می‌آد که کتاب/فصل معلوم نیست یا اون درس
هنوز در Qdrant نیست.
سه تصمیم که اینجا گرفته شده و دلیلشان:
1. **خروجی متنِ ساده است، نه LaTeX/markdown.** اپ پاسخ را خام نشان می‌دهد؛
`\\frac{1}{2}` روی گوشی عیناً دیده می‌شد. پرامپت متنِ ساده می‌خواهد و
`mathtext.to_plain` پشتش تورِ ایمنی است.
2. **حل کامل و مرحله‌به‌مرحله، با وارسی پیش از جوابِ نهایی.** تصمیمِ صاحبِ
پروژه (۲۰۲۶-۰۹-۲۴): دستیار راه‌حلِ کامل را توضیح می‌دهد، نه فقط راهنمایی.
وارسی (جای‌گذاری، مشتق‌گیریِ دوباره) عمداً در پرامپت است: پاسخی از
gpt-4o-mini مشتقِ x³ − 6x² + 9x را 3x² − 12x نوشته بود.
3. **لحن سه سطح دارد** (دبستان / متوسطه‌ی اول / متوسطه‌ی دوم)، نه فقط نامِ پایه.
"""
import logging
import time
@@ -13,6 +24,7 @@ from collections.abc import Iterator
from django.conf import settings
from . import clients
from .mathtext import StreamSanitizer, to_plain
logger = logging.getLogger(__name__)
@@ -21,68 +33,200 @@ _DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح
# ⚠️ با هر تغییرِ معنادار در متنِ پرامپت‌های پایین، این را یکی جلو ببرید.
# روی هر پیامِ دستیار ذخیره می‌شود تا وقتی لایک/دیسلایکِ کاربرها جمع شد،
# بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست.
PROMPT_VERSION = 'tutor-v2-grade-aware'
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه
# کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود.
PROMPT_VERSION = 'tutor-v3-stepwise-plain'
# پایه‌ها تا ششم دبستان‌اند، هفتم تا نهم متوسطه‌ی اول و دهم به بعد متوسطه‌ی دوم.
_SECONDARY_GRADES = {'هفتم', 'هشتم', 'نهم', 'دهم', 'یازدهم', 'دوازدهم'}
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
_UPPER = {'دهم', 'یازدهم', 'دوازدهم'}
# چند پیامِ آخرِ گفتگو که همراهِ سوالِ تازه فرستاده می‌شود. سه رفت‌وبرگشت
# برای «نفهمیدم» و «یه مثال دیگه» کافی است و هزینه‌ی توکن را محدود نگه می‌دارد.
HISTORY_TURNS = 6
_HISTORY_CHARS = 1500
def grade_tier(grade_title: str) -> str:
"""'primary' | 'middle' | 'upper' | '' (نامعلوم)."""
grade = (grade_title or '').strip()
if grade in _PRIMARY:
return 'primary'
if grade in _MIDDLE:
return 'middle'
if grade in _UPPER:
return 'upper'
return ''
def _persona(grade_title: str) -> tuple[str, str]:
"""(توصیفِ معلم، توصیفِ دانش‌آموز) متناسب با پایه."""
grade = (grade_title or '').strip()
if grade in _SECONDARY_GRADES:
tier = grade_tier(grade)
if tier in ('middle', 'upper'):
return f'معلمِ ریاضیِ پایه‌ی {grade}', f'دانش‌آموزِ پایه‌ی {grade}'
if grade:
return f'معلمِ پایه‌ی {grade} دبستان', f'دانش‌آموزِ پایه‌ی {grade}'
return 'معلمِ فارسی‌زبان', 'دانش‌آموز'
if tier == 'primary':
return f'معلمِ پایه‌ی {grade} دبستان', f'دانش‌آموزِ پایه‌ی {grade} دبستان'
return 'معلمِ ریاضیِ فارسی‌زبان', 'دانش‌آموز'
_TONE = {
'primary': (
'با یک بچه‌ی دبستانی حرف می‌زنی: مهربان، صبور و تشویق‌کننده.\n'
'- جمله‌ها کوتاه و ساده باشند؛ هر مرحله فقط یک کار.\n'
'- با مثال‌های روزمره توضیح بده (سیب، شکلات، پول، خط‌کش، ساعت).\n'
'- اگر اصطلاحِ کتاب را به‌کار می‌بری، اول با زبانِ خودمانی معنی‌اش را بگو.\n'
'- حداکثر یک ایموجیِ تشویقی در کلِ پاسخ.'
),
'middle': (
'با یک نوجوانِ متوسطه‌ی اول حرف می‌زنی: دوستانه و روشن، مثلِ معلمی که '
'بچه‌ها دوستش دارند.\n'
'- اصطلاح‌های کتاب را به‌کار ببر و هرجا لازم است با یک جمله معنی‌اش را بگو.\n'
'- قاعده‌ای را که در هر مرحله استفاده می‌کنی نام ببر (مثلاً «اتحادِ مربعِ دوجمله‌ای»).'
),
'upper': (
'با یک دانش‌آموزِ دبیرستان حرف می‌زنی: دقیق، فشرده و محترمانه.\n'
'- نمادگذاری و اصطلاح‌های همان کتاب را به‌کار ببر.\n'
'- در هر مرحله قاعده یا قضیه‌ای را که استفاده می‌کنی نام ببر '
'(مثلاً «مشتقِ حاصل‌ضرب»، «قضیه‌ی فیثاغورس»، «رفعِ ابهامِ 0/0»).\n'
'- شرط‌ها را صریح چک کن: دامنه، مخرجِ مخالفِ صفر، زیرِ رادیکالِ نامنفی، '
'علامتِ عبارت، واحدِ زاویه.'
),
'': 'دوستانه، روشن و دقیق حرف بزن.',
}
_FORMAT = (
'قالبِ پاسخ (مهم — پاسخ در یک اپِ موبایل به‌صورتِ متنِ ساده نمایش داده می‌شود):\n'
'- فقط متنِ ساده. هیچ LaTeX ننویس (نه \\( \\)، نه \\[ \\]، نه \\frac، نه $) و '
'هیچ markdown ننویس (نه #، نه **، نه جدول).\n'
'- برای ریاضی از نمادهای یونیکد استفاده کن: x²، x³، √2، ∛8، ×، ÷، ±، ≤، ≥، ≠، '
'≈، π، ∞، ∈، ∪، ∩، →، °، f′(x).\n'
'{numbers}\n'
'- هر مرحله در یک خطِ جدا و شماره‌دار باشد: «۱) …»، «۲) …». هر فرمولِ مهم '
'در خطِ خودش.\n'
'- بی‌مقدمه شروع کن؛ سوال را تکرار نکن و حرفِ اضافه نزن.'
)
# در کتاب‌های درسیِ ایران «/» بینِ دو رقم ممیزِ اعشار است (۲/۵ یعنی دو و نیم).
# بچه‌ی دبستانی همین را می‌شناسد؛ پس برای او کسر با کلمه نوشته می‌شود. از
# هفتم به بعد فرمول‌ها طولانی‌اند و (x+1)/(x−2) بی‌جایگزین است، پس ممیز نقطه
# می‌شود تا «/» فقط یک معنا داشته باشد.
_NUMBERS = {
'primary': (
'- مثلِ کتابِ درسیِ خودش بنویس: رقم‌های فارسی، و ممیزِ اعشار با «/» (۲/۵ یعنی '
'دو و نیم). کسر را با کلمه بنویس: «۳ بر ۴» یا «سه‌چهارم» — هرگز با «/».'
),
'default': (
'- اعدادِ داخلِ فرمول را با رقمِ انگلیسی بنویس و ممیزِ اعشار را با نقطه '
'(3.5). «/» فقط یعنی کسر: (x+1)/(x−2).'
),
}
_METHOD = (
'روشِ پاسخ:\n'
'- اگر سوال یک مسئله است، آن را کامل و مرحله‌به‌مرحله حل کن تا دانش‌آموز '
'بتواند مسیر را دنبال کند؛ در هر مرحله بگو چه می‌کنیم و چرا. هیچ مرحله‌ی '
'محاسبه‌ای را جا نینداز.\n'
'- اگر سوال مفهومی است، مفهوم را با یک مثالِ عددیِ کوتاه و حل‌شده توضیح بده.\n'
'- دقت از همه‌چیز مهم‌تر است. پیش از نوشتنِ جوابِ نهایی آن را وارسی کن '
'(جواب را در معادله جای‌گذاری کن، مشتق یا ساده‌سازی را دوباره انجام بده، '
'بزرگیِ عدد را تخمین بزن). اگر در وارسی اشتباهی دیدی، راه‌حل را اصلاح کن؛ '
'هرگز جوابی را که وارسی نکرده‌ای ننویس.\n'
'- اگر مسئله جوابِ مشخصی دارد، خطِ آخرِ حل این باشد: «✅ جواب: …».\n'
'- اگر اطلاعاتِ مسئله ناقص است (مثلاً به شکلی اشاره می‌کند که نمی‌بینی)، '
'بگو چه چیزی لازم است، به‌جای حدس زدن.\n'
'- اگر دانش‌آموز می‌گوید نفهمیده یا توضیحِ بیشتر می‌خواهد، از گفتگوی قبلی '
'بفهم درباره‌ی چیست و همان را ساده‌تر، با راهی دیگر یا مثالی تازه توضیح بده.\n'
'- در پایانِ حلِ یک مسئله، یک تمرینِ کوتاهِ مشابه بده: «حالا خودت امتحان کن: …» '
'(بدونِ جوابش).\n'
'- اگر سوال ربطی به درس و مدرسه ندارد، مؤدبانه و کوتاه بگو که کارت کمک در '
'ریاضی است و بچه را به درس برگردان.'
)
def system_prompt(grade_title: str = '') -> str:
"""
قبلاً «معلمِ دبستانی» ثابت بود. برای پایه‌های نهم/دهم این هم لحن را بچگانه
می‌کرد و هم مدل را وادار می‌کرد مفاهیمی مثل اتحاد، مثلثات و تابع را تا حدِ
دبستان ساده کند — یعنی دقیقاً همان‌جایی که بچه‌ها گیر می‌کنند بی‌فایده
می‌شد. حالا لحن از پایه می‌آید.
قبلاً «معلمِ دبستانی» ثابت بود؛ بعد فقط نامِ پایه در آن آمد. حالا لحن،
روش (حلِ کامل + وارسی) و قالب (متنِ ساده) هر سه صریح‌اند.
"""
teacher, _ = _persona(grade_title)
teacher, student = _persona(grade_title)
numbers = _NUMBERS['primary' if grade_tier(grade_title) == 'primary' else 'default']
return (
f'تو یک {teacher} و فارسی‌زبان هستی. پاسخ‌ها را روشن، دقیق و '
'گام‌به‌گام می‌دهی و از نمادها و اصطلاح‌های ریاضیِ همان پایه استفاده '
'می‌کنی؛ نه ساده‌تر از سطحِ کتاب، نه پیچیده‌تر از آن.'
f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} '
f'با یک {student} حرف می‌زنی. همیشه فارسی جواب بده.\n\n'
f'{_TONE[grade_tier(grade_title)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}'
)
def _prompt_with_context(question: str, contexts: list[str], grade_title: str = '') -> str:
joined = '\n\n---\n\n'.join(contexts)
teacher, student = _persona(grade_title)
return f"""
تو {teacher} هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ
{student} پاسخِ روشن و گام‌به‌گام بده. لازم نیست پاسخ عیناً در متن باشد؛
می‌توانی از اطلاعاتِ متن نتیجه‌گیری و توضیح بدهی.
اگر مسئله چند مرحله دارد، مرحله‌ها را جدا و شماره‌گذاری‌شده بنویس.
فقط اگر متنِ درس آشکارا درباره‌ی موضوعِ دیگری‌ست و هیچ ربطی به سوال ندارد،
بنویس: «این موضوع در این درس پوشش داده نشده است.»
def _user_prompt(question: str, contexts: list[str], focus: str = '') -> str:
asked = f'سوالِ دانش‌آموز: {question}'
if focus:
asked = f'{focus}\n\n{asked}'
if not contexts:
return asked if focus else question
excerpts = '\n\n'.join(f'[{i}] {text}' for i, text in enumerate(contexts, 1))
return (
'چند بخش از کتابِ درسیِ همین دانش‌آموز که با جستجو پیدا شده‌اند '
'(ممکن است همه مربوط نباشند):\n\n'
f'{excerpts}\n\n'
'— پایانِ بخش‌های کتاب —\n'
'اگر به سوال مربوط‌اند، از روش و نمادگذاریِ همان‌ها پیروی کن؛ اگر مربوط '
'نیستند نادیده‌شان بگیر و از دانشِ خودت در سطحِ همین پایه جواب بده. '
'متنِ کتاب از روی تصویر خوانده شده و ممکن است غلطِ تایپی داشته باشد؛ '
'حسابِ خودت را ملاک بگذار. در متنِ کتاب «/» بینِ دو رقم ممیزِ اعشار است '
'(۳/۱۰۴ یعنی 3.104)، نه کسر.\n\n'
f'{asked}'
)
متنِ درس:
{joined}
سوالِ {student}: {question}
def chat_model_for(grade_title: str = '') -> str:
"""
def _prompt_without_context(question: str, grade_title: str = '') -> str:
teacher, student = _persona(grade_title)
return f"""
تو {teacher} هستی. به این سوالِ یک {student} به فارسی، روشن و گام‌به‌گام
پاسخ بده.
سوال: {question}
مدلِ چت برای این پایه. `OPENAI_CHAT_MODEL_UPPER` (اختیاری) فقط برای
دهم تا دوازدهم است، جایی که حسابان/مثلثات مدلِ قوی‌تری می‌خواهد و
هزینه‌ی اضافه‌اش ارزش دارد؛ خالی یعنی همه‌ی پایه‌ها یک مدل.
"""
upper = getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or ''
if upper and grade_tier(grade_title) == 'upper':
return upper
return settings.OPENAI_CHAT_MODEL
def is_reasoning_model(model: str) -> bool:
"""مدل‌های reasoning (o-series، gpt-5) temperature نمی‌پذیرند."""
name = (model or '').lower().split('/')[-1]
return name.startswith(('o1', 'o3', 'o4', 'gpt-5'))
def completion_kwargs(model: str) -> dict:
if is_reasoning_model(model):
effort = getattr(settings, 'OPENAI_CHAT_REASONING_EFFORT', '') or ''
return {'reasoning_effort': effort} if effort else {}
return {'temperature': 0.2}
def build_messages(question: str, context_chunks: list[dict] | None,
grade_title: str = '', history: list[dict] | None = None,
focus: str = '') -> list[dict]:
"""
history: [{'role': 'user'|'assistant', 'text': ...}, ...] به ترتیبِ زمان.
بدونِ history، «نفهمیدم» یا «یه مثال دیگه بزن» (که خودِ اپ به‌عنوانِ
دکمه‌ی پیشنهادی نشان می‌دهد) برای مدل هیچ معنایی نداشت.
"""
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
messages = [{'role': 'system', 'content': system_prompt(grade_title)}]
for turn in (history or [])[-HISTORY_TURNS:]:
role = 'assistant' if turn.get('role') == 'assistant' else 'user'
text = (turn.get('text') or '').strip()
if text:
messages.append({'role': role, 'content': text[:_HISTORY_CHARS]})
messages.append({'role': 'user', 'content': _user_prompt(question, contexts, focus)})
return messages
def generate_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '') -> tuple[str, list[str], dict]:
grade_title: str = '', history: list[dict] | None = None,
model: str | None = None, focus: str = '') -> tuple[str, list[str], dict]:
"""
context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[].
خروجی: (answer, follow_ups, meta)
@@ -90,70 +234,111 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
`meta` همان چیزی‌ست که روی پیامِ دستیار ذخیره می‌شود: مدل، نسخه‌ی پرامپت،
توکنِ مصرف‌شده و مدتِ تولید. بدونِ اینها نه می‌شود فهمید کدام نسخه‌ی پرامپت
بهتر جواب می‌دهد، نه اینکه چقدر از سهمیه‌ی API خرج شده است.
`model` فقط برای مقایسه‌ی مدل‌ها (`rag_model_benchmark`) است.
"""
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
prompt = (
_prompt_with_context(question, contexts, grade_title) if contexts
else _prompt_without_context(question, grade_title)
)
model = model or chat_model_for(grade_title)
messages = build_messages(question, context_chunks, grade_title, history, focus)
client = clients.get_openai_client()
started = time.monotonic()
completion = client.chat.completions.create(
model=settings.OPENAI_CHAT_MODEL,
messages=[
{'role': 'system', 'content': system_prompt(grade_title)},
{'role': 'user', 'content': prompt},
],
temperature=0.3,
model=model, messages=messages, **completion_kwargs(model),
)
latency_ms = int((time.monotonic() - started) * 1000)
answer = (completion.choices[0].message.content or '').strip()
raw = (completion.choices[0].message.content or '').strip()
answer = to_plain(raw, fraction_word=grade_tier(grade_title) == 'primary')
if not answer:
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
usage = getattr(completion, 'usage', None)
meta = {
'model': settings.OPENAI_CHAT_MODEL,
'model': model,
'promptVersion': PROMPT_VERSION,
'latencyMs': latency_ms,
'promptTokens': getattr(usage, 'prompt_tokens', None) or 0,
'completionTokens': getattr(usage, 'completion_tokens', None) or 0,
'hadContext': bool(contexts),
'hadContext': any(c.get('summary') for c in (context_chunks or [])),
# آیا تورِ ایمنی چیزی را عوض کرد؟ نشانِ این است که مدل قاعده‌ی «متنِ
# ساده» را رعایت نکرده — برای مقایسه‌ی مدل‌ها در rag_model_benchmark.
'sanitized': answer != raw,
}
return answer, list(_DEFAULT_FOLLOW_UPS), meta
def stream_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '') -> Iterator[str]:
grade_title: str = '', history: list[dict] | None = None,
focus: str = '') -> Iterator[str]:
"""
همون generate_answer ولی توکن‌به‌توکن (برای SSE). فقط قطعه‌های متنِ پاسخ
را yield می‌کند؛ follow_upها ثابت‌اند (_DEFAULT_FOLLOW_UPS) و caller خودش
اضافه‌شان می‌کند.
"""
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
prompt = (
_prompt_with_context(question, contexts, grade_title) if contexts
else _prompt_without_context(question, grade_title)
)
همون generate_answer ولی تکه‌به‌تکه (برای SSE). فقط متنِ پاسخ را yield
می‌کند؛ follow_upها ثابت‌اند و caller خودش اضافه‌شان می‌کند.
متن خط‌به‌خط از StreamSanitizer رد می‌شود، چون یک دستورِ LaTeX ممکن است
بینِ دو تکه‌ی استریم نصف شود.
"""
model = chat_model_for(grade_title)
client = clients.get_openai_client()
stream = client.chat.completions.create(
model=settings.OPENAI_CHAT_MODEL,
messages=[
{'role': 'system', 'content': system_prompt(grade_title)},
{'role': 'user', 'content': prompt},
],
temperature=0.3,
model=model,
messages=build_messages(question, context_chunks, grade_title, history, focus),
stream=True,
**completion_kwargs(model),
)
sanitizer = StreamSanitizer(fraction_word=grade_tier(grade_title) == 'primary')
for event in stream:
if not event.choices:
continue
delta = event.choices[0].delta.content or ''
if delta:
yield delta
clean = sanitizer.feed(delta)
if clean:
yield clean
tail = sanitizer.flush()
if tail:
yield tail
def question_focus(question_id, user_id) -> str:
"""
متنِ سوالی از اپ که دانش‌آموز درباره‌اش می‌پرسد، با گزینه‌ها، آخرین جوابِ
خودش و جوابِ درست — تا دستیار بگوید اشتباه دقیقاً کجاست.
سینیور خواسته بود ثابت شود دستیار «همان‌جایی که بچه‌ها گیر می‌کنند» کمک
می‌کند؛ بدونِ این، دستیار سوالی را که بچه همین الان غلط زده نمی‌دید.
خالی برمی‌گرداند اگر سوال پیدا نشود (هرگز خطا نمی‌دهد).
"""
try:
from content.models import Question, UserAnswer
question = Question.objects.prefetch_related('choices').filter(pk=int(question_id)).first()
except (TypeError, ValueError):
return ''
except Exception: # noqa: BLE001 — اپ content در دسترس نیست
logger.exception('question_focus: خواندنِ سوال %s شکست خورد', question_id)
return ''
if question is None:
return ''
choices = list(question.choices.all())
correct = next((c.value for c in choices if c.is_correct), '')
last = (
UserAnswer.objects.filter(user_id=user_id, question=question)
.select_related('choice').order_by('-id').first()
)
lines = [
'دانش‌آموز درباره‌ی این سوالِ تمرینِ اپ می‌پرسد:',
f'صورتِ سوال: {question.prompt}',
'گزینه‌ها: ' + ' | '.join(c.value for c in choices),
f'جوابِ درست: {correct}',
]
if last is not None:
verdict = 'درست' if last.correct else 'غلط'
lines.append(f'آخرین جوابِ دانش‌آموز: {last.choice.value} ({verdict})')
if not last.correct:
lines.append(
'اول بگو اشتباهِ احتمالیِ او در کدام مرحله بوده (بدونِ سرزنش)، بعد '
'راه‌حلِ درست را مرحله‌به‌مرحله توضیح بده.'
)
return '\n'.join(lines)
def default_follow_ups() -> list[str]:

308
rag/services/mathtext.py Normal file
View File

@@ -0,0 +1,308 @@
"""
تبدیلِ LaTeX و markdown در پاسخِ دستیار به متنِ ساده با نمادهای یونیکد.
چرا لازم است: صفحه‌ی «از پی بپرس» پاسخ را همان‌طور که هست، به‌صورتِ متنِ خام
نشان می‌دهد. مدل‌ها برای ریاضی عادت دارند `\\( \\frac{1}{2} \\)` و `### مرحله ۱`
بنویسند؛ روی گوشی این‌ها عیناً دیده می‌شوند و فرمولِ حسابان به رشته‌ای از
بک‌اسلش و آکولاد تبدیل می‌شود. پرامپت از مدل می‌خواهد متنِ ساده بنویسد، ولی
مدل همیشه گوش نمی‌دهد — این ماژول تورِ ایمنیِ قطعی پشتِ آن است.
قاعده‌ی مهم: آکولادی که جزوِ LaTeX نیست دست نمی‌خورد. مجموعه‌ها در ریاضیِ نهم
با `{1, 2, 3}` نوشته می‌شوند و نباید پاک شوند.
"""
import re
from contextvars import ContextVar
# برای دبستان «/» ممیزِ اعشار است (مثلِ کتابشان)، پس کسر «۳ بر ۴» نوشته می‌شود.
_FRACTION_WORD: ContextVar[bool] = ContextVar('fraction_word', default=False)
_SUPERSCRIPT = str.maketrans('0123456789+-=()nixyk', '⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱˣʸᵏ')
_SUBSCRIPT = str.maketrans('0123456789+-=()', '₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎')
_SUP_CHARS = set('0123456789+-=()nixyk')
_SUB_CHARS = set('0123456789+-=()')
_SYMBOLS = {
'times': '×', 'cdot': '·', 'div': '÷', 'pm': '±', 'mp': '∓',
'leq': '≤', 'le': '≤', 'geq': '≥', 'ge': '≥', 'neq': '≠', 'ne': '≠',
'approx': '≈', 'simeq': '≃', 'equiv': '≡', 'infty': '∞',
'pi': 'π', 'alpha': 'α', 'beta': 'β', 'gamma': 'γ', 'theta': 'θ',
'Delta': 'Δ', 'delta': 'δ', 'lambda': 'λ', 'mu': 'μ', 'sigma': 'σ',
'Sigma': 'Σ', 'phi': 'φ', 'varphi': 'φ', 'omega': 'ω', 'epsilon': 'ε',
'varepsilon': 'ε', 'rho': 'ρ', 'tau': 'τ',
'rightarrow': '→', 'to': '→', 'leftarrow': '←', 'Rightarrow': '⇒',
'implies': '⇒', 'Leftrightarrow': '⇔', 'iff': '⇔', 'mapsto': '↦',
'in': '∈', 'notin': '∉', 'subset': '⊂', 'subseteq': '⊆', 'supset': '⊃',
'cup': '∪', 'cap': '∩', 'emptyset': '∅', 'varnothing': '∅',
'forall': '∀', 'exists': '∃', 'angle': '∠', 'perp': '⊥', 'parallel': '∥',
'triangle': '△', 'circ': '∘', 'degree': '°', 'prime': '′',
'ldots': '…', 'dots': '…', 'cdots': '⋯', 'int': '∫', 'sum': 'Σ',
'prod': '∏', 'therefore': '∴', 'propto': '∝', 'neg': '¬',
'quad': ' ', 'qquad': ' ', 'lbrace': '{', 'rbrace': '}',
'setminus': '−', 'mid': '|', 'vert': '|',
}
_PREFIX_SIGNS = {'pm', 'mp', 'neg', 'angle', 'triangle', 'forall', 'exists'}
_BLACKBOARD = {'R': 'ℝ', 'N': 'ℕ', 'Z': 'ℤ', 'Q': 'ℚ', 'C': 'ℂ'}
# \sin و \log و ... فقط بک‌اسلششان حذف می‌شود.
_FUNCTIONS = {
'sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'log', 'ln', 'exp', 'lim',
'max', 'min', 'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'det', 'gcd',
}
# دستورهایی که فقط محتوای آرگومانشان می‌ماند.
_UNWRAP = {'text', 'textbf', 'textit', 'mathrm', 'mathbf', 'mathit', 'operatorname',
'boxed', 'displaystyle', 'mbox', 'underline', 'bm', 'textrm'}
_DROP = {'left', 'right', 'big', 'Big', 'bigg', 'Bigg', 'displaystyle', 'limits',
'nonumber', 'hline', 'noindent'}
def _read_group(s: str, i: int) -> tuple[str, int]:
"""از s[i] یک آرگومانِ LaTeX می‌خواند: {…} با آکولادِ تودرتو، یا یک نویسه."""
while i < len(s) and s[i] == ' ':
i += 1
if i >= len(s):
return '', i
if s[i] != '{':
if s[i] == '\\':
m = re.match(r'\\[A-Za-z]+', s[i:])
if m:
return m.group(0), i + len(m.group(0))
return s[i], i + 1
depth, j = 0, i
while j < len(s):
if s[j] == '\\' and j + 1 < len(s):
j += 2
continue
if s[j] == '{':
depth += 1
elif s[j] == '}':
depth -= 1
if depth == 0:
return s[i + 1:j], j + 1
j += 1
return s[i + 1:], len(s)
_ATOM = re.compile(r'√?[\d.]+|√?\w|\(.*\)')
def _needs_parens(expr: str) -> bool:
"""«2h» در مخرج باید (2h) شود، وگرنه 1/2h یعنی h/2."""
return not _ATOM.fullmatch(expr.strip())
def _script(body: str, table, allowed: set, marker: str) -> str:
if body and all(ch in allowed for ch in body):
return body.translate(table)
return f'{marker}({body})' if len(body) > 1 else f'{marker}{body}'
def _convert(s: str) -> str:
"""یک عبارتِ LaTeX را (بازگشتی) به یونیکد تبدیل می‌کند."""
out: list[str] = []
i = 0
while i < len(s):
ch = s[i]
if ch == '\\':
if i + 1 < len(s) and not s[i + 1].isalpha():
nxt = s[i + 1]
if nxt == '\\':
out.append('\n')
elif nxt in '{}%$&#_':
out.append(nxt)
elif nxt in ',;:! ':
out.append(' ' if nxt != '!' else '')
else:
out.append(nxt)
i += 2
continue
m = re.match(r'\\([A-Za-z]+)', s[i:])
name = m.group(1)
i += len(m.group(0))
if name == 'frac' or name in ('dfrac', 'tfrac'):
num, i = _read_group(s, i)
den, i = _read_group(s, i)
num, den = _convert(num), _convert(den)
num = f'({num})' if _needs_parens(num) else num
den = f'({den})' if _needs_parens(den) else den
out.append(f'{num} بر {den}' if _FRACTION_WORD.get() else f'{num}/{den}')
elif name == 'sqrt':
index = ''
if i < len(s) and s[i] == '[':
close = s.find(']', i)
if close != -1:
index, i = s[i + 1:close], close + 1
body, i = _read_group(s, i)
body = _convert(body)
root = (index.translate(_SUPERSCRIPT) if index else '') + '√'
out.append(root + (f'({body})' if len(body) > 1 else body))
elif name == 'mathbb':
body, i = _read_group(s, i)
out.append(_BLACKBOARD.get(body, body))
elif name in ('vec', 'overrightarrow'):
body, i = _read_group(s, i)
out.append(_convert(body) + '⃗')
elif name in ('overline', 'bar', 'widehat', 'hat'):
body, i = _read_group(s, i)
out.append(_convert(body))
elif name in ('begin', 'end'):
_env, i = _read_group(s, i)
elif name in _UNWRAP:
if i < len(s) and s[i] == '{':
body, i = _read_group(s, i)
out.append(_convert(body))
elif name in _DROP:
# \left( → ( — خودِ پرانتز در دورِ بعدِ حلقه نوشته می‌شود.
if name in ('left', 'right') and i < len(s) and s[i] == '.':
i += 1
elif name in _SYMBOLS:
out.append(_SYMBOLS[name])
# «\pm 2» → «±2»: نمادِ یک‌طرفه به عددش می‌چسبد.
if name in _PREFIX_SIGNS:
while i < len(s) and s[i] == ' ':
i += 1
elif name in _FUNCTIONS:
out.append(name)
else:
out.append(name)
elif ch in '^_':
# مدل «x^10» را به معنیِ ده می‌نویسد، نه «x¹0» — رقم‌ها با هم خوانده می‌شوند.
digits = re.match(r'\d+', s[i + 1:])
if digits:
body, i = digits.group(0), i + 1 + len(digits.group(0))
else:
body, i = _read_group(s, i + 1)
if ch == '^' and body in ('\\circ', 'circ', '°'):
out.append('°')
continue
if ch == '^' and body in ('\\prime', "'"):
out.append('′')
continue
body = _convert(body)
if ch == '_' and ''.join(out).endswith('lim'):
out.append(f'({body})')
continue
if ch == '^':
out.append(_script(body, _SUPERSCRIPT, _SUP_CHARS, '^'))
else:
out.append(_script(body, _SUBSCRIPT, _SUB_CHARS, '_'))
elif ch == '{':
# آکولادِ گروه‌بندیِ LaTeX (بعد از پاک شدنِ دستورِ صاحبش)
body, i = _read_group(s, i)
out.append(_convert(body))
elif ch == '&':
i += 1
else:
out.append(ch)
i += 1
return ''.join(out)
# \( … \) و \[ … \] و $$ … $$ و $ … $ — محتوا تبدیل و جداکننده حذف می‌شود.
_MATH_SPANS = re.compile(
r'\\\[(.+?)\\\]|\\\((.+?)\\\)|\$\$(.+?)\$\$|(?<![\w$])\$([^$\n]+?)\$(?!\w)',
re.DOTALL,
)
# LaTeXِ بی‌جداکننده که مدل گاهی وسطِ متن می‌نویسد: \frac{..}{..}، x^{2}، \sqrt{..}
_BARE_LATEX = re.compile(r'\\[A-Za-z{}]|[\^_]\{|(?<=[\w)])\^\d')
def _math_span(match: re.Match) -> str:
body = next(g for g in match.groups() if g is not None)
# بلوکِ نمایشی را مدل خودش در خطِ جدا می‌نویسد؛ خطِ خالیِ اضافه نمی‌سازیم.
if '\\\\' in body: # چندخطی (\\ یا cases/aligned)
return '\n'.join(' '.join(line.split()) for line in _convert(body).strip().split('\n'))
return ' '.join(_convert(body).split())
def _strip_markdown(text: str) -> str:
lines = []
for line in text.split('\n'):
stripped = line.strip()
if re.fullmatch(r'\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?', stripped):
continue # خطِ جداکننده‌ی جدولِ markdown
if stripped.startswith('|') and stripped.endswith('|') and stripped.count('|') >= 2:
cells = [c.strip() for c in stripped.strip('|').split('|')]
line = ' | '.join(cells)
line = re.sub(r'^\s{0,3}#{1,6}\s+', '', line)
line = re.sub(r'^(\s*)[-*]\s+', r'\1• ', line)
line = re.sub(r'^\s*(-{3,}|\*{3,}|_{3,})\s*$', '', line)
line = re.sub(r'\*\*(.+?)\*\*', r'\1', line)
line = re.sub(r'__(.+?)__', r'\1', line)
line = line.replace('`', '')
lines.append(line.strip()) # تورفتگی در حبابِ راست‌به‌چپ معنایی ندارد
return '\n'.join(lines)
def to_plain(text: str, *, fraction_word: bool = False) -> str:
"""
پاسخِ مدل → متنِ ساده‌ی قابلِ خواندن روی گوشی.
fraction_word: کسرِ LaTeX به‌صورتِ «a بر b» (برای دبستان) به‌جای a/b.
"""
if not text:
return text
token = _FRACTION_WORD.set(fraction_word)
try:
return _to_plain(text)
finally:
_FRACTION_WORD.reset(token)
def _to_plain(text: str) -> str:
text = _MATH_SPANS.sub(_math_span, text)
if _BARE_LATEX.search(text):
text = '\n'.join(_convert(line) if _BARE_LATEX.search(line) else line
for line in text.split('\n'))
text = _strip_markdown(text)
text = re.sub(r'[ \t]+\n', '\n', text)
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
class StreamSanitizer:
"""
همان to_plain برای پاسخِ استریمی. یک دستورِ LaTeX ممکن است بینِ دو تکه‌ی
استریم نصف شود (`\\fr` + `ac{1}{2}`)، پس متن تا پایانِ هر خط نگه داشته و
خط‌به‌خط تبدیل می‌شود؛ یک بلوکِ `\\[ … \\]` چندخطی هم تا بسته شدنش نگه
داشته می‌شود.
"""
def __init__(self, *, fraction_word: bool = False):
self._fraction_word = fraction_word
self._buffer = ''
self._emitted_any = False
self._newlines = 0 # خط‌های خالیِ معلق؛ مثلِ to_plain حداکثر دوتا
def _ready_cut(self) -> int:
cut = self._buffer.rfind('\n')
if cut == -1:
return -1
head = self._buffer[:cut + 1]
# بلوکِ نمایشیِ باز را نصفه نفرست.
if head.count('\\[') > head.count('\\]') or head.count('$$') % 2:
return -1
return cut + 1
def feed(self, delta: str) -> str:
self._buffer += delta
cut = self._ready_cut()
if cut == -1:
return ''
head, self._buffer = self._buffer[:cut], self._buffer[cut:]
return self._emit(head, final=False)
def flush(self) -> str:
head, self._buffer = self._buffer, ''
return self._emit(head, final=True)
def _emit(self, chunk: str, *, final: bool) -> str:
plain = to_plain(chunk, fraction_word=self._fraction_word)
if not plain:
# خطِ واقعاً خالی خطِ خالی است؛ خطی که محتوایش حذف شد (جداکننده‌ی
# جدول) مثلِ to_plain هیچ ردی نمی‌گذارد.
if not chunk.strip():
self._newlines += chunk.count('\n')
return ''
leading = len(chunk) - len(chunk.lstrip('\n'))
prefix = '\n' * min(self._newlines + leading, 2) if self._emitted_any else ''
self._emitted_any = True
self._newlines = 0 if final else len(chunk) - len(chunk.rstrip('\n'))
return prefix + plain

View File

@@ -107,15 +107,23 @@ def _payload_text(payload: dict) -> str:
دو شکلِ payload در دادهٔ تیمِ دیگه هست:
- درس‌های متنی (علوم/اجتماعی): کلیدِ `summary`
- ریاضی: کلیدِ `generalSolution` (+ problem/solution) و بدونِ `summary`
⚠️ راه‌حلی که `rag_verify_solutions` با آن مخالفت کرده (`disagrees`) به مدل
نمی‌رسد؛ فقط روش و صورتِ مسئله می‌رود. پیش‌تر همان راه‌حلِ غلط (مثلاً «در
[−π, π] دو نوسانِ کامل») به‌عنوانِ «متنِ درس» به دستیار داده می‌شد و دستیار
تکرارش می‌کرد — حدودِ ۱۳٪ تکه‌های کتاب‌های دهم تا دوازدهم این‌طورند.
"""
if payload.get('summary'):
return payload['summary']
solution = payload.get('solution', '')
if payload.get('solution_verdict') == 'disagrees':
solution = ''
parts = [
payload.get('generalSolution', ''),
payload.get('problem', ''),
payload.get('solution', ''),
('روش', payload.get('generalSolution', '')),
('مسئله', payload.get('problem', '')),
('راه‌حلِ کتاب', solution),
]
joined = '\n'.join(p for p in parts if p).strip()
joined = '\n'.join(f'{label}: {text}' for label, text in parts if text).strip()
return joined or payload.get('text', '')

302
rag/tests_tutor_quality.py Normal file
View File

@@ -0,0 +1,302 @@
"""
کیفیتِ پاسخِ دستیار (۲۰۲۶-۰۹-۲۵): متنِ ساده به‌جای LaTeX، لحنِ سه‌سطحی، حلِ
مرحله‌به‌مرحله با وارسی، حافظه‌ی گفتگو، و کنار گذاشتنِ راه‌حل‌های غلطِ کتاب.
هر تست یکی از ایرادهایی را می‌پوشاند که روی جواب‌های واقعی دیده شد؛
توضیحِ هرکدام کنارِ خودِ کد است.
"""
from types import SimpleNamespace
from unittest.mock import patch
from django.core.cache import cache
from django.test import SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .services.clients import reset_clients_cache
from .services.mathtext import StreamSanitizer, to_plain
RAG_ON = dict(
OPENAI_API_KEY='test-openai', QDRANT_API_KEY='test-qdrant', RAG_FORCE_MOCK=False,
)
class PlainMathTextTests(SimpleTestCase):
def test_inline_and_display_latex_become_unicode(self):
text = r'مشتق \(f(x)=x^{3}-6x^2+9x\) برابر است با:' '\n' r'\[ f\'(x) = 3x^2 - 12x + 9 \]'
self.assertEqual(to_plain(text), "مشتق f(x)=x³-6x²+9x برابر است با:\nf'(x) = 3x² - 12x + 9")
def test_fractions_keep_their_meaning(self):
# «1/2h» یعنی h/2 — مخرجِ چندنویسه‌ای باید پرانتز بگیرد.
self.assertEqual(to_plain(r'\(\frac{a}{2h}\)'), 'a/(2h)')
self.assertEqual(to_plain(r'\(\frac{x+1}{x-2}\)'), '(x+1)/(x-2)')
self.assertEqual(to_plain(r'\(\frac{\sqrt{3}}{2}\)'), '√3/2')
def test_primary_fractions_are_written_in_words(self):
# در کتابِ دبستان «۲/۵» یعنی دو و نیم؛ کسر نباید با «/» نوشته شود.
self.assertEqual(to_plain(r'\(\frac{3}{4}\)', fraction_word=True), '3 بر 4')
def test_roots_limits_degrees_and_symbols(self):
self.assertEqual(to_plain(r'\(\sqrt[3]{8} = 2\)'), '³√8 = 2')
self.assertEqual(to_plain(r'\(\lim_{x \to 0} \frac{\sin x}{x} = 1\)'), 'lim(x → 0) (sin x)/x = 1')
self.assertEqual(to_plain(r'\(30^\circ\)'), '30°')
self.assertEqual(to_plain(r'\(a \leq b \neq c \times d\)'), 'a ≤ b ≠ c × d')
self.assertEqual(to_plain('x^10 + y^2'), 'x¹⁰ + y²')
def test_set_braces_survive(self):
# مجموعه‌ها در ریاضیِ نهم با آکولاد نوشته می‌شوند.
self.assertEqual(to_plain('A = {1, 2, 3}'), 'A = {1, 2, 3}')
self.assertEqual(to_plain(r'\(A = \{1, 2\} \cup B\)'), 'A = {1, 2} ∪ B')
def test_markdown_is_removed(self):
text = '### مرحله ۱\n**نکته:** این مهم است\n| x | y |\n|---|---|\n| 1 | 2 |\n- اول'
self.assertEqual(to_plain(text), 'مرحله ۱\nنکته: این مهم است\nx | y\n1 | 2\n• اول')
def test_plain_text_passes_through(self):
text = '۱) دو طرف را بر ۲ تقسیم می‌کنیم.\n۲) x = 3\n✅ جواب: 3'
self.assertEqual(to_plain(text), text)
def test_stream_output_equals_batch_output_however_it_is_split(self):
text = (
'### حل\nاول \\(x^{2}\\) را حساب می‌کنیم:\n\\[\n\\frac{a+b}{2}\n\\]\n\n\n'
'| a | b |\n|---|---|\n| 1 | 2 |\n**تمام** \\(\\sqrt{16}=4\\)'
)
for step in (1, 2, 3, 7, 50):
sanitizer = StreamSanitizer()
out = ''.join(sanitizer.feed(text[i:i + step]) for i in range(0, len(text), step))
out += sanitizer.flush()
self.assertEqual(out, to_plain(text), f'step={step}')
class TutorPromptTests(SimpleTestCase):
def test_three_tiers_have_different_voices(self):
from rag.services.generation import system_prompt
primary, middle, upper = system_prompt('سوم'), system_prompt('هشتم'), system_prompt('یازدهم')
self.assertIn('دبستانی', primary)
self.assertIn('سیب', primary)
self.assertNotIn('دبستان', middle)
self.assertIn('دبیرستان', upper)
self.assertIn('دامنه', upper)
def test_every_prompt_demands_full_steps_a_check_and_plain_text(self):
from rag.services.generation import system_prompt
for grade in ('سوم', 'نهم', 'دوازدهم', ''):
prompt = system_prompt(grade)
self.assertIn('مرحله‌به‌مرحله', prompt)
self.assertIn('وارسی', prompt)
self.assertIn('✅ جواب', prompt)
self.assertIn('LaTeX', prompt)
def test_decimal_notation_follows_the_book_for_primary(self):
from rag.services.generation import system_prompt
self.assertIn('۲/۵ یعنی', system_prompt('ششم'))
self.assertIn('(3.5)', system_prompt('دهم'))
def test_history_goes_before_the_new_question(self):
from rag.services.generation import HISTORY_TURNS, build_messages
history = [{'role': 'user', 'text': f'سوال {i}'} if i % 2 == 0
else {'role': 'assistant', 'text': f'جواب {i}'} for i in range(10)]
messages = build_messages('نفهمیدم', [{'summary': 'متنِ کتاب'}], 'نهم', history)
self.assertEqual(messages[0]['role'], 'system')
self.assertEqual(len(messages), 1 + HISTORY_TURNS + 1)
self.assertEqual(messages[-2], {'role': 'assistant', 'content': 'جواب 9'})
self.assertTrue(messages[-1]['content'].endswith('سوالِ دانش‌آموز: نفهمیدم'))
self.assertIn('متنِ کتاب', messages[-1]['content'])
def test_reasoning_models_get_no_temperature(self):
from rag.services.generation import completion_kwargs
self.assertEqual(completion_kwargs('gpt-4o-mini'), {'temperature': 0.2})
with override_settings(OPENAI_CHAT_REASONING_EFFORT='low'):
self.assertEqual(completion_kwargs('o4-mini'), {'reasoning_effort': 'low'})
self.assertEqual(completion_kwargs('gpt-5-mini'), {'reasoning_effort': 'low'})
@override_settings(OPENAI_CHAT_MODEL='cheap', OPENAI_CHAT_MODEL_UPPER='strong')
def test_upper_grades_can_have_their_own_model(self):
from rag.services.generation import chat_model_for
self.assertEqual(chat_model_for('دوازدهم'), 'strong')
self.assertEqual(chat_model_for('نهم'), 'cheap')
self.assertEqual(chat_model_for(''), 'cheap')
@override_settings(**RAG_ON)
class GenerateAnswerSanitizesTests(TestCase):
def setUp(self):
reset_clients_cache()
def tearDown(self):
reset_clients_cache()
def _client(self, reply):
completions = SimpleNamespace(create=lambda **kw: SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content=reply))], usage=None))
return SimpleNamespace(chat=SimpleNamespace(completions=completions))
def test_latex_from_the_model_never_reaches_the_student(self):
from rag.services.generation import generate_answer
with patch('rag.services.clients.get_openai_client',
return_value=self._client(r'۱) \(x^2 = 4\)' '\n' r'✅ جواب: \(x = \pm 2\)')):
answer, _f, meta = generate_answer('x² = 4', None, 'نهم')
self.assertEqual(answer, '۱) x² = 4\n✅ جواب: x = ±2')
self.assertTrue(meta['sanitized'])
self.assertEqual(meta['promptVersion'], 'tutor-v3-stepwise-plain')
class DisputedSolutionTests(SimpleTestCase):
def _payload(self, verdict):
return {'generalSolution': 'روش', 'problem': 'مسئله', 'solution': 'راه‌حلِ کتاب',
'solution_verdict': verdict}
def test_a_solution_the_checker_disputes_is_not_shown_to_the_tutor(self):
from rag.services.retrieval import _payload_text
text = _payload_text(self._payload('disagrees'))
self.assertIn('مسئله', text)
self.assertNotIn('راه‌حلِ کتاب:', text)
def test_agreed_and_unchecked_solutions_still_go(self):
from rag.services.retrieval import _payload_text
for verdict in ('agrees', 'unjudgeable', None):
self.assertIn('راه‌حلِ کتاب: راه‌حلِ کتاب', _payload_text(self._payload(verdict)))
@override_settings(**RAG_ON, RAG_ANSWER_CACHE_TTL=300)
class ConversationMemoryTests(TestCase):
def setUp(self):
cache.clear()
reset_clients_cache()
self.client = APIClient()
def tearDown(self):
cache.clear()
def _user(self, phone):
user = User.objects.create(phone=phone, is_guest=False)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return client
def _ask(self, client, text, conversation_id=None):
body = {'userQuestion': text, 'subject': 'ریاضی', 'grade': 'نهم', 'lessonNumber': 4}
if conversation_id:
body['conversationId'] = conversation_id
return client.post('/api/ai/ask', body, format='json')
def test_follow_up_sees_the_conversation_and_searches_with_the_topic(self):
client = self._user('09120000901')
retrieve = patch('rag.services.retrieval.retrieve_context',
return_value=([], {'gradeTitle': 'نهم'}))
generate = patch('rag.services.generation.generate_answer',
return_value=('جوابِ توان', [], {}))
with retrieve as retrieve_mock, generate as generate_mock:
first = self._ask(client, '۲ به توان منفی ۳ چند است؟')
self._ask(client, 'نفهمیدم', first.json()['conversationId'])
history = generate_mock.call_args.kwargs['history']
self.assertEqual([t['text'] for t in history], ['۲ به توان منفی ۳ چند است؟', 'جوابِ توان'])
self.assertIn('توان منفی', retrieve_mock.call_args.args[0])
def test_a_follow_up_is_never_served_from_someone_elses_cache(self):
replies = iter(['جوابِ اولِ الف', 'جوابِ «نفهمیدم»ِ الف', 'جوابِ اولِ ب', 'جوابِ «نفهمیدم»ِ ب'])
retrieve = patch('rag.services.retrieval.retrieve_context',
return_value=([], {'gradeTitle': 'نهم'}))
generate = patch('rag.services.generation.generate_answer',
side_effect=lambda *a, **k: (next(replies), [], {}))
with retrieve, generate:
a, b = self._user('09120000902'), self._user('09120000903')
conv_a = self._ask(a, 'اتحاد چیست؟').json()['conversationId']
self._ask(a, 'نفهمیدم', conv_a)
conv_b = self._ask(b, 'تجزیه چیست؟').json()['conversationId']
second_b = self._ask(b, 'نفهمیدم', conv_b)
self.assertEqual(second_b.json()['answer'], 'جوابِ «نفهمیدم»ِ ب')
@override_settings(**RAG_ON)
class ProviderStateTests(TestCase):
"""اعتبارِ تمام‌شده باید در /api/health دیده شود، نه فقط در لاگ."""
def setUp(self):
cache.clear()
reset_clients_cache()
user = User.objects.create(phone='09120000904', is_guest=False)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def tearDown(self):
cache.clear()
def _ask(self, generate):
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
patch('rag.services.generation.generate_answer', **generate):
return self.client.post('/api/ai/ask', {'userQuestion': 'سلام'}, format='json')
def test_exhausted_credit_shows_up_in_health_and_clears_on_recovery(self):
quota = RuntimeError("Error code: 403 - insufficient user quota, remaining quota: $-0.001")
self.assertEqual(self._ask({'side_effect': quota}).status_code, 503)
health = self.client.get('/api/health').json()
self.assertEqual(health['integrations']['aiProvider']['state'], 'quota_exhausted')
self.assertTrue(any('GapGPT' in w for w in health['warnings']))
self.assertEqual(self._ask({'return_value': ('جواب', [], {})}).status_code, 200)
health = self.client.get('/api/health').json()
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
def test_an_ordinary_network_error_does_not_claim_the_credit_is_gone(self):
self.assertEqual(self._ask({'side_effect': ConnectionError('timeout')}).status_code, 503)
health = self.client.get('/api/health').json()
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
@override_settings(**RAG_ON)
class QuestionFocusTests(TestCase):
"""«چرا جوابم غلط بود؟» — دستیار باید همان سوال و جوابِ دانش‌آموز را ببیند."""
def setUp(self):
from content.models import Choice, Grade, Lesson, Node, Question, Unit
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='ف', order=1),
title='ن', order=1)
self.question = Question.objects.create(lesson=Lesson.objects.create(node=node, title='د'),
type='multiple_choice', prompt='۲ به توان منفی ۳؟')
self.right = Choice.objects.create(question=self.question, value='1/8', is_correct=True)
self.wrong = Choice.objects.create(question=self.question, value='-8', is_correct=False)
user = User.objects.create(phone='09120000905', is_guest=False, grade_id=grade.id)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def tearDown(self):
cache.clear()
def test_the_tutor_sees_the_question_and_the_wrong_answer(self):
self.client.post(f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': self.wrong.id}, format='json')
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
patch('rag.services.generation.generate_answer',
return_value=('جواب', [], {})) as generate:
response = self.client.post('/api/ai/ask', {
'userQuestion': 'چرا غلط شد؟', 'questionId': str(self.question.id),
}, format='json')
self.assertEqual(response.status_code, 200)
focus = generate.call_args.kwargs['focus']
self.assertIn('۲ به توان منفی ۳؟', focus)
self.assertIn('-8 (غلط)', focus)
self.assertIn('جوابِ درست: 1/8', focus)
def test_an_overlong_question_is_refused_before_any_paid_call(self):
with patch('rag.services.generation.generate_answer') as generate:
response = self.client.post('/api/ai/ask', {'userQuestion': 'x' * 2001}, format='json')
self.assertEqual(response.status_code, 400)
generate.assert_not_called()

View File

@@ -19,24 +19,37 @@ def has_unlimited_hearts(user_id: int) -> bool:
).exists()
def activate_subscription(subscription_id: int):
def activate_subscription(subscription_id: int, plan_id: int | None = None):
"""
بعد از تایید پرداخت (payment webhook) صدا زده می‌شود؛ اشتراک را active
می‌کند و expires_at را بر اساس Plan.duration_days محاسبه می‌کند.
مستقل از caller اتمیک است چون از چند جا (payment webhook، bazaar verify) صدا زده می‌شود.
بعد از تاییدِ پرداخت صدا زده می‌شود (کال‌بکِ زرین‌پال، تاییدِ بازار).
- پلن از *سفارشِ پرداخت‌شده* می‌آید (`plan_id`)، نه از Subscription؛ پلنِ
روی Subscription با شروعِ خریدِ دیگری عوض می‌شد (باگِ «ماهانه بده، سالانه
بگیر»). سفارش‌های قدیمیِ بی‌plan_id همان رفتارِ قبلی را دارند.
- تمدید از تاریخِ انقضای فعلی حساب می‌شود، نه از امروز؛ پیش‌تر تمدیدِ زودتر
از موعد روزهای باقی‌مانده را پاک می‌کرد.
"""
from .models import Plan
with transaction.atomic():
try:
subscription = Subscription.objects.select_for_update().get(pk=subscription_id)
except Subscription.DoesNotExist:
return None
old_status = subscription.status
duration_days = subscription.plan.duration_days if subscription.plan else 30
plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None
plan = plan or subscription.plan
duration_days = plan.duration_days if plan else 30
now = timezone.now()
still_running = (
old_status == 'active' and subscription.expires_at is not None
and subscription.expires_at > now
)
start = subscription.expires_at if still_running else now
subscription.status = 'active'
subscription.expires_at = timezone.now() + timezone.timedelta(days=duration_days)
subscription.save(update_fields=['status', 'expires_at'])
subscription.plan = plan
subscription.expires_at = start + timezone.timedelta(days=duration_days)
subscription.save(update_fields=['status', 'plan', 'expires_at'])
status_changed.send(
sender='subscription.Subscription', object_id=subscription.id,
old_status=old_status, new_status='active',

View File

@@ -10,7 +10,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from payment.models import Order
from payment.services import create_order
from payment.services import create_order, reconcile_user_orders
from .models import Plan, Subscription
from .serializers import (
@@ -19,6 +19,7 @@ from .serializers import (
PurchaseResponseSerializer,
SubscriptionStatusResponseSerializer,
)
from .services import has_unlimited_hearts
class GuestAwarePurchaseThrottle(ScopedRateThrottle):
@@ -128,7 +129,17 @@ class PurchaseView(APIView):
'order_id': prior_subscription.order_id,
}
if existing_order is not None:
# اشتراکِ فعال با باز کردنِ صفحه‌ی خرید دست نمی‌خورد. پیش‌تر وضعیتش
# همین‌جا «pending» می‌شد و کاربرِ پولی اگر خرید را تمام نمی‌کرد
# مزیتش را از دست می‌داد. پلن و تمدید از خودِ سفارش می‌آیند
# (activate_subscription)، پس لازم نیست Subscription الان عوض شود.
currently_active = prior_subscription is not None and has_unlimited_hearts(user.id)
if currently_active:
subscription = prior_subscription
subscription_is_new = False
prior_snapshot = None
elif existing_order is not None:
# Retry path: leave an existing subscription untouched, only
# create one if it's genuinely missing.
if prior_subscription is None:
@@ -158,6 +169,7 @@ class PurchaseView(APIView):
currency=plan.currency,
subscription_id=subscription.id,
idempotency_key=idempotency_key,
plan_id=plan.id,
)
except Exception:
# Guard by pk+status+plan_id+order_id+version so this cleanup only
@@ -178,7 +190,7 @@ class PurchaseView(APIView):
Subscription.objects.filter(**guard).update(**prior_snapshot)
raise
if subscription.order_id != order.id:
if subscription.order_id != order.id and not currently_active:
with transaction.atomic():
subscription.order_id = order.id
subscription.save(update_fields=['order_id'])
@@ -194,9 +206,13 @@ class SubscriptionStatusView(APIView):
@extend_schema(responses=SubscriptionStatusResponseSerializer, tags=['Subscription (subscription)'])
def get(self, request):
# پرداختی که تأییدش در کال‌بک گم شده، اینجا دوباره سنجیده می‌شود.
reconcile_user_orders(request.user.id)
subscription = Subscription.objects.filter(user_id=request.user.id).first()
data = {
'active': bool(subscription and subscription.status == 'active'),
# وضعیتِ «active» با گذشتِ تاریخ خودبه‌خود عوض نمی‌شود؛ همان شرطی که
# قلبِ نامحدود را می‌دهد ملاک است، وگرنه اشتراکِ منقضی «فعال» دیده می‌شد.
'active': has_unlimited_hearts(request.user.id),
'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None,
'expiresAt': subscription.expires_at if subscription else None,
}