diff --git a/.env.example b/.env.example index 1bfaf97..de1ad20 100644 --- a/.env.example +++ b/.env.example @@ -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 یک‌بار و دستی از diff --git a/.gitignore b/.gitignore index e917508..093de53 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ staticfiles/ # نسخه‌ی پشتیبانِ تغییرهای دستیِ روی سرور که deploy.sh کنار می‌گذارد. .deploy-backups/ + +# دیتابیسِ فایلیِ تست‌ها (config/settings.py → DATABASES.TEST) +.test-db.sqlite3* diff --git a/CLAUDE.md b/CLAUDE.md index f88716d..2f2de23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `), 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 `). 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 زنده diff --git a/accounts/authentication.py b/accounts/authentication.py index d3b7cbb..46bc42c 100644 --- a/accounts/authentication.py +++ b/accounts/authentication.py @@ -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 @@ -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 diff --git a/accounts/migrations/0004_otpcode_failed_attempts_user_tokens_valid_after.py b/accounts/migrations/0004_otpcode_failed_attempts_user_tokens_valid_after.py new file mode 100644 index 0000000..101eda1 --- /dev/null +++ b/accounts/migrations/0004_otpcode_failed_attempts_user_tokens_valid_after.py @@ -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), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 2963425..00b633f 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -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}" diff --git a/accounts/serializers.py b/accounts/serializers.py index 25a9bff..3dbe7a3 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -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() diff --git a/accounts/tests.py b/accounts/tests.py index 252f168..efac68b 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -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', diff --git a/accounts/tests_audit_fixes.py b/accounts/tests_audit_fixes.py new file mode 100644 index 0000000..0f2804e --- /dev/null +++ b/accounts/tests_audit_fixes.py @@ -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) diff --git a/accounts/urls.py b/accounts/urls.py index 2414dd3..919345f 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -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'), ] diff --git a/accounts/views.py b/accounts/views.py index 6b3ed1d..ed52b94 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -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,12 +305,24 @@ class LoginView(APIView): valid = True if not valid: + _count_failed_attempt(phone) return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST) - user, created = User.objects.get_or_create( - phone=phone, - defaults={'is_guest': False, 'name': ''}, - ) + 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': ''}, + ) if user.is_guest: user.is_guest = False user.save(update_fields=['is_guest']) @@ -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 — تمدید توکن""" diff --git a/affiliate/serializers.py b/affiliate/serializers.py index 9df065e..6359843 100644 --- a/affiliate/serializers.py +++ b/affiliate/serializers.py @@ -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): diff --git a/affiliate/services.py b/affiliate/services.py new file mode 100644 index 0000000..ada5fe0 --- /dev/null +++ b/affiliate/services.py @@ -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 diff --git a/affiliate/views.py b/affiliate/views.py index b6ea267..4942637 100644 --- a/affiliate/views.py +++ b/affiliate/views.py @@ -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 diff --git a/ai_tutor/provider_status.py b/ai_tutor/provider_status.py new file mode 100644 index 0000000..5f379fc --- /dev/null +++ b/ai_tutor/provider_status.py @@ -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: ثبتِ بازگشت شکست خورد') diff --git a/ai_tutor/serializers.py b/ai_tutor/serializers.py index b3d6fcf..55b16a4 100644 --- a/ai_tutor/serializers.py +++ b/ai_tutor/serializers.py @@ -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) diff --git a/ai_tutor/views.py b/ai_tutor/views.py index d62f9d0..ec9510b 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -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,18 +394,22 @@ 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, ) - answer_cache.set(cache_key, { - 'answer': answer_text, 'followUps': follow_ups, - 'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta, - 'generationMeta': generation_meta, - }) + provider_status.record_success() + if cacheable: + answer_cache.set(cache_key, { + 'answer': answer_text, 'followUps': follow_ups, + 'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta, + 'generationMeta': generation_meta, + }) else: 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), } - answer_cache.set(cache_key, { - 'answer': answer_text, 'followUps': follow_ups, - 'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta, - 'generationMeta': generation_meta, - }) - except Exception: + if cacheable: + answer_cache.set(cache_key, { + 'answer': answer_text, 'followUps': follow_ups, + 'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta, + 'generationMeta': generation_meta, + }) + 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 diff --git a/config/health.py b/config/health.py index 459330b..f5dd20e 100644 --- a/config/health.py +++ b/config/health.py @@ -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ها مشترک نیست.') diff --git a/config/middleware.py b/config/middleware.py index 06eaf94..7f4b0a1 100644 --- a/config/middleware.py +++ b/config/middleware.py @@ -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) diff --git a/config/settings.py b/config/settings.py index 356b6af..44dfbd9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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')) diff --git a/content/receivers.py b/content/receivers.py index de097d6..3d4b738 100644 --- a/content/receivers.py +++ b/content/receivers.py @@ -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() diff --git a/content/serializers.py b/content/serializers.py index d8418ce..fa90400 100644 --- a/content/serializers.py +++ b/content/serializers.py @@ -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): diff --git a/content/services.py b/content/services.py index 8b8c17c..56eb070 100644 --- a/content/services.py +++ b/content/services.py @@ -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) diff --git a/content/tests.py b/content/tests.py index dd94494..f7c8460 100644 --- a/content/tests.py +++ b/content/tests.py @@ -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('_', '')) diff --git a/content/tests_island_progress.py b/content/tests_island_progress.py new file mode 100644 index 0000000..0dfcae9 --- /dev/null +++ b/content/tests_island_progress.py @@ -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') diff --git a/content/tests_node_completion.py b/content/tests_node_completion.py index 801eee3..2287a3b 100644 --- a/content/tests_node_completion.py +++ b/content/tests_node_completion.py @@ -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() diff --git a/content/views.py b/content/views.py index 6cb2216..b51f157 100644 --- a/content/views.py +++ b/content/views.py @@ -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 diff --git a/discount/serializers.py b/discount/serializers.py index e75affc..f46070f 100644 --- a/discount/serializers.py +++ b/discount/serializers.py @@ -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): diff --git a/discount/views.py b/discount/views.py index 6f5d507..a10bfad 100644 --- a/discount/views.py +++ b/discount/views.py @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md index aefd3f5..8516ac0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 را از شماره‌اش می‌گیرد، نه از شمارنده‌ی دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده می‌شود. پایه‌ی diff --git a/docs/operations.md b/docs/operations.md index 5a93235..203157d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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` توکن‌های ورود را امضا می‌کند.** بهترین حالت این است که > اصلاً ست نشود تا خودِ تنظیمات یکی بسازد — آن‌وقت کلیدی وجود ندارد که لو diff --git a/family/views.py b/family/views.py index d954ecd..fc871ca 100644 --- a/family/views.py +++ b/family/views.py @@ -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(): diff --git a/payment/management/__init__.py b/payment/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payment/management/commands/__init__.py b/payment/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payment/management/commands/payment_reconcile.py b/payment/management/commands/payment_reconcile.py new file mode 100644 index 0000000..e8e138e --- /dev/null +++ b/payment/management/commands/payment_reconcile.py @@ -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 شد.') diff --git a/payment/migrations/0007_order_plan_id.py b/payment/migrations/0007_order_plan_id.py new file mode 100644 index 0000000..269454c --- /dev/null +++ b/payment/migrations/0007_order_plan_id.py @@ -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), + ), + ] diff --git a/payment/models.py b/payment/models.py index 5476c3e..484b66e 100644 --- a/payment/models.py +++ b/payment/models.py @@ -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') diff --git a/payment/permissions.py b/payment/permissions.py index d45fde5..4f1fc97 100644 --- a/payment/permissions.py +++ b/payment/permissions.py @@ -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) diff --git a/payment/serializers.py b/payment/serializers.py index 38bc640..976bb97 100644 --- a/payment/serializers.py +++ b/payment/serializers.py @@ -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.""" diff --git a/payment/services.py b/payment/services.py index 0be0901..1ac7947 100644 --- a/payment/services.py +++ b/payment/services.py @@ -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) diff --git a/payment/tests.py b/payment/tests.py index 41a52ca..533df53 100644 --- a/payment/tests.py +++ b/payment/tests.py @@ -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: # همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی # بقیه‌ی تست‌ها اثر نذاره. diff --git a/payment/tests_audit_fixes.py b/payment/tests_audit_fixes.py new file mode 100644 index 0000000..808d8e7 --- /dev/null +++ b/payment/tests_audit_fixes.py @@ -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()) diff --git a/payment/urls.py b/payment/urls.py index 3d242d6..58bde45 100644 --- a/payment/urls.py +++ b/payment/urls.py @@ -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/', OrderDetailView.as_view(), name='payment-order-detail'), diff --git a/payment/views.py b/payment/views.py index 2268b27..4f9dcbd 100644 --- a/payment/views.py +++ b/payment/views.py @@ -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, diff --git a/progress/migrations/0004_seyar_characters.py b/progress/migrations/0004_seyar_characters.py new file mode 100644 index 0000000..1a53410 --- /dev/null +++ b/progress/migrations/0004_seyar_characters.py @@ -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)] diff --git a/progress/tests_live_gamification.py b/progress/tests_live_gamification.py index 60c605b..9aa265d 100644 --- a/progress/tests_live_gamification.py +++ b/progress/tests_live_gamification.py @@ -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}, diff --git a/rag/management/commands/rag_model_benchmark.py b/rag/management/commands/rag_model_benchmark.py new file mode 100644 index 0000000..5b92109 --- /dev/null +++ b/rag/management/commands/rag_model_benchmark.py @@ -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}%' + ) diff --git a/rag/management/commands/rag_verify_solutions.py b/rag/management/commands/rag_verify_solutions.py index 1b02665..8968cd8 100644 --- a/rag/management/commands/rag_verify_solutions.py +++ b/rag/management/commands/rag_verify_solutions.py @@ -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 # مسئله‌ای که به شکلِ کتاب ارجاع می‌دهد و محتوایش را ننوشته، بدونِ تصویر diff --git a/rag/services/answer_cache.py b/rag/services/answer_cache.py index b3514a4..918904c 100644 --- a/rag/services/answer_cache.py +++ b/rag/services/answer_cache.py @@ -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 diff --git a/rag/services/clients.py b/rag/services/clients.py index 467968f..5222a3f 100644 --- a/rag/services/clients.py +++ b/rag/services/clients.py @@ -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() diff --git a/rag/services/generation.py b/rag/services/generation.py index 7721c22..d257ea5 100644 --- a/rag/services/generation.py +++ b/rag/services/generation.py @@ -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]: diff --git a/rag/services/mathtext.py b/rag/services/mathtext.py new file mode 100644 index 0000000..ac71d04 --- /dev/null +++ b/rag/services/mathtext.py @@ -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'\\\[(.+?)\\\]|\\\((.+?)\\\)|\$\$(.+?)\$\$|(? 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 diff --git a/rag/services/retrieval.py b/rag/services/retrieval.py index b284f8e..9c1c6b2 100644 --- a/rag/services/retrieval.py +++ b/rag/services/retrieval.py @@ -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', '') diff --git a/rag/tests_tutor_quality.py b/rag/tests_tutor_quality.py new file mode 100644 index 0000000..b3026b2 --- /dev/null +++ b/rag/tests_tutor_quality.py @@ -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() diff --git a/subscription/services.py b/subscription/services.py index b24d99a..e1defb7 100644 --- a/subscription/services.py +++ b/subscription/services.py @@ -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', diff --git a/subscription/views.py b/subscription/views.py index cd5df99..60160ad 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -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, }