tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown, with rag/services/mathtext.py converting whatever still slips through; full step-by-step solutions with a check before the final answer; three tones by grade; the last 6 messages of the conversation go to the model and follow-ups are no longer served from another user's cache; book solutions the checker disputes are kept out of the context; a questionId gives the tutor the practice question and the student's wrong answer. /api/health reports when the AI credit runs out. rag_model_benchmark compares chat models on verified book problems (run on the server; credit ran out mid-run). Map: only lessons that have questions count toward finishing an island, lessonIds[0] / nextLessonId is always the next lesson to play, and a finished island stays finished. The Seyar characters (sigma/delta/pi) are seeded. Audit fixes, each reproduced before fixing: - the plan is stored on the order, so paying a monthly order after starting a yearly checkout no longer grants a year - opening checkout no longer demotes an active subscription; renewals extend from the current expiry; expired subscriptions are not reported active - one discount code per order and only before the gateway; a 100% code completes the purchase; lost Zarinpal confirmations are re-verified - the unused /api/payment/webhook "mark paid" endpoint is removed - XP counts only answers recorded on the server - 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout - a guest who signs up keeps their account and progress (test phones excluded) - affiliate commission on the first real payment; guests excluded - parents with several children no longer get a 500 - SQLite runs transactions IMMEDIATE, since it ignores select_for_update - admin login is rate-limited; display names drop phone numbers and links; userQuestion is capped at 2000 characters - server-side heart enforcement exists behind HEARTS_ENFORCED (off until the frontend handles 403 no_hearts) Not done on purpose: rotating the server's secret key and moving to Postgres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -148,11 +148,14 @@ NUM_PROXIES=1
|
||||
KAVENEGAR_SENDER=
|
||||
# OTP_SMS_TEMPLATE=کد ورود شما به تدریسینو: {code}
|
||||
|
||||
# کلیدِ endpointهای داخلی (ingest در اپ rag) و امضای وبهوکِ پرداخت. با
|
||||
# DEBUG=1 مقدارِ توسعه دارند؛ با DEBUG=0 خالیاند و آن مسیرها بسته میمانند
|
||||
# کلیدِ endpointهای داخلی (ingest در اپ rag، ساختِ سفارشِ سرور-به-سرور). با
|
||||
# DEBUG=1 مقدارِ توسعه دارد؛ با DEBUG=0 خالی است و آن مسیرها بسته میمانند
|
||||
# تا وقتی مقدارِ واقعی بگذارید.
|
||||
INTERNAL_SERVICE_KEY=
|
||||
PAYMENT_WEBHOOK_SECRET=
|
||||
|
||||
# با صفر قلب جلوی جواب دادن گرفته شود؟ (۱ = بله). فقط وقتی فرانت برای ۴۰۳ِ
|
||||
# no_hearts صفحهی «قلبها تمام شد» دارد.
|
||||
# HEARTS_ENFORCED=0
|
||||
|
||||
# پرداختِ درونبرنامهایِ کافهبازار. بدونِ این چهار تا، مسیرِ بازار
|
||||
# fail-closed میماند (payment/bazaar.py). REFRESH_TOKEN یکبار و دستی از
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ staticfiles/
|
||||
|
||||
# نسخهی پشتیبانِ تغییرهای دستیِ روی سرور که deploy.sh کنار میگذارد.
|
||||
.deploy-backups/
|
||||
|
||||
# دیتابیسِ فایلیِ تستها (config/settings.py → DATABASES.TEST)
|
||||
.test-db.sqlite3*
|
||||
|
||||
60
CLAUDE.md
60
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 <token>`), backed by `accounts.Session`. Missing/invalid header → anonymous request, not a hard failure; each view's permission class decides if auth is required.
|
||||
**Auth is custom, not Django's.** `accounts.User` is deliberately *not* `AUTH_USER_MODEL` — it's an independent business model. Auth is a stateless signed bearer token (`django.core.signing`, no JWT library) issued/verified in `accounts/authentication.py` (`TadrisinoTokenAuthentication`, header `Authorization: Bearer <token>`). The access token is only a signed `user_id` (1 h); `accounts.Session` holds refresh tokens, and `POST /api/auth/logout` revokes older access tokens via `User.tokens_valid_after`. Missing/invalid header → anonymous request, not a hard failure; each view's permission class decides if auth is required.
|
||||
|
||||
**Apps are decoupled by convention**: instead of real FKs across app boundaries, code uses loose refs (`user_id`, `object_id` + `label`) so any app can be split out later. Don't add cross-app FKs — follow the existing loose-reference pattern.
|
||||
|
||||
@@ -56,7 +72,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
|
||||
|
||||
**Structured logging**: `config.middleware.APIRequestLoggingMiddleware` (last in `MIDDLEWARE`, closest to the view) logs every `/api/` request as JSON (method/path/status/duration/user, no body/headers/secrets) via `config/log_formatters.py`.
|
||||
|
||||
**SQLite** is current DB (`OPTIONS.timeout=20` to tolerate concurrent-write locks, e.g. campaigns tests); README notes Postgres migration is still pending.
|
||||
**SQLite** is current DB (`OPTIONS.timeout=20`, and `transaction_mode: IMMEDIATE` so every `atomic()` takes the write lock up front — SQLite ignores `select_for_update`, so without it those guards locked nothing). Tests run on a file DB (`.test-db.sqlite3`). Postgres migration is still pending.
|
||||
|
||||
**SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success.
|
||||
|
||||
@@ -371,7 +387,45 @@ preferred setup, because there is then no key to leak. **Never let the server ru
|
||||
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
|
||||
anyone who can read this repo can forge a token for any user.**
|
||||
|
||||
## وضعیت فعلی (۲۰۲۶-۰۹-۲۴) — این را اول بخوان
|
||||
## وضعیت ۲۰۲۶-۰۹-۲۵ — این را اول بخوان
|
||||
|
||||
کیفیتِ دستیار و گیرِ نقشه (جزئیات و دلیلها در `docs/architecture.md`). **۵۵۵ تست سبز.**
|
||||
این کارها روی سرور **هنوز دیپلوی نشدهاند**، مگر خلافش اینجا ثبت شده باشد.
|
||||
|
||||
- **پرامپت `tutor-v3-stepwise-plain`**: متنِ ساده (بی LaTeX/markdown، با تورِ ایمنیِ
|
||||
`rag/services/mathtext.py`)، حلِ کامل و مرحلهبهمرحله با وارسی و «✅ جواب»، لحنِ
|
||||
سهسطحی، حافظهی ۶ پیامِ آخرِ گفتگو، و حذفِ راهحلهای `disagrees` از context.
|
||||
- **جزیرهها**: فقط درسهای سوالدار شمرده میشوند، `nextLessonId` + `lessonIds[0]`
|
||||
همیشه درسِ بعدی است، و جزیرهی تمامشده تمام میماند. بدونِ این، فرانتِ فعلی
|
||||
(که همیشه `lessonIds[0]` را باز میکند) هیچ جزیرهای را تمام نمیکرد.
|
||||
- **سهیار** (`sigma`/`delta`/`pi`، باز شدن با ۰/۱۰۰/۳۰۰ امتیاز) با migration
|
||||
`progress.0004`؛ کاتالوگ روی سرور خالی بود.
|
||||
- **`/api/health` حالا تمام شدنِ اعتبارِ GapGPT را نشان میدهد** (`aiProvider`).
|
||||
- ⛔ **اعتبارِ GapGPT در ۲۰۲۶-۰۹-۲۵ تمام شد** (وسطِ `rag_model_benchmark`؛ مانده
|
||||
−$0.0016). تا شارژ، دستیار برای همه ۵۰۳ میدهد. انتخابِ مدلِ چت منتظرِ همین است:
|
||||
هزینهی واقعیِ هر پاسخ با پرامپتِ تازه — gpt-4o-mini ۰٫۰۴۴، gpt-4.1-mini ۰٫۱۱۶،
|
||||
gpt-5-mini ۰٫۱۷۱، gpt-5.4-mini ۰٫۲۹، o4-mini ۰٫۴۱ سنت. دقتشان هنوز اندازه نشده.
|
||||
**تصمیمِ صاحبِ پروژه (۰۹-۲۵): فعلاً gpt-4o-mini میماند** (ارزانترین). اولین
|
||||
کاندیدا برای سنجش بعد از شارژ: `deepseek-v4-flash` (قیمتِ رسمی از 4o-mini کمتر،
|
||||
ریاضیِ خیلی قویتر، ولی فارسی و حالتِ بدونِ thinkingاش اندازه نشده) و
|
||||
`gpt-4.1-mini`. تیمِ Rag_backend برای جوابِ ریاضی `o3-mini` استفاده میکند.
|
||||
پوشهی موقتِ سنجش روی سرور: `~/mathapp-v2/.bench/` (پس از اتمام پاک شود).
|
||||
- فرانت: PRِ ما (#3) بسته شد؛ هرچه فرانت باید بکند فقط در
|
||||
`~/Desktop/کارهای-فرانت-تدریسینو.html` است (نه PR). شاخهی محلیِ
|
||||
`zareian/ai-chat-readability` فقط مرجع است.
|
||||
|
||||
**بازبینیِ بدبینانهی همان روز و رفعها** (۵۷۹ تست سبز؛ جزئیات در `docs/architecture.md`):
|
||||
پلن روی سفارش (باگِ «ماهانه بده، سالانه بگیر»)، صفحهی خرید اشتراکِ فعال را
|
||||
دست نمیزند، تمدید از انقضای فعلی، یک کدِ تخفیف پیش از درگاه و کدِ ۱۰۰٪ = پرداخت،
|
||||
بازسنجیِ خودکارِ پرداختِ تأییدنشده، حذفِ `/api/payment/webhook`، پورسانتِ affiliate
|
||||
روی پرداختِ واقعی (مهمان بیرون)، امتیاز فقط از جوابِ ثبتشده، `HEARTS_ENFORCED`
|
||||
(خاموش)، OTPِ ۶ رقمی با ۵ حدس، خروج از حساب (`tokens_valid_after`)، مهمانِ
|
||||
ثبتنامکننده همان کاربر میماند، والدِ چندفرزندی، سقفِ ورودِ ادمین، پاکسازیِ نامِ
|
||||
نمایشی، `questionId` در دستیار، SQLite با `transaction_mode: IMMEDIATE`.
|
||||
**عمداً انجام نشد** (تصمیمِ صاحبِ پروژه: برای ارائه نقطهی شکست میسازند):
|
||||
عوض کردنِ `DJANGO_SECRET_KEY`ِ سرور (همه را بیرون میاندازد) و مهاجرت به Postgres.
|
||||
|
||||
## وضعیت ۲۰۲۶-۰۹-۲۴
|
||||
|
||||
سرور روی `81348ce` به بعد است (سرویس واقعاً ریاستارت شد)، **۵۳۰ تست سبز**، ۶۰ اندپوینت. **پروژه در این تاریخ بسته شد**؛ فایلهای تحویل (گزارشِ ۲۲، راهنمای ارائه، zipِ بکاند) در `~/Desktop/گزارشات Tadrisino/گزارش-۲۲/` بهروزند. آن روز کلِ
|
||||
فرانت (`MathApp_frontend`، upstream `main` = `d223336`) صفحهبهصفحه با API زنده
|
||||
|
||||
@@ -71,6 +71,14 @@ def decode_access_token(token: str) -> int:
|
||||
return int(value)
|
||||
|
||||
|
||||
def _issued_at(token: str) -> int:
|
||||
"""
|
||||
ثانیهی صدورِ توکن. قالبِ TimestampSigner «مقدار:زمان:امضا» است و زمان
|
||||
base62؛ فقط بعد از unsignِ موفق صدا زده میشود، پس ساختار معتبر است.
|
||||
"""
|
||||
return signing.b62_decode(token.rsplit(':', 2)[-2])
|
||||
|
||||
|
||||
class TadrisinoTokenAuthentication(BaseAuthentication):
|
||||
"""
|
||||
هدر مورد انتظار: Authorization: Bearer <access_token>
|
||||
@@ -91,7 +99,10 @@ class TadrisinoTokenAuthentication(BaseAuthentication):
|
||||
|
||||
token = parts[1]
|
||||
user_id = decode_access_token(token)
|
||||
return (_get_request_user(user_id), token)
|
||||
user = _get_request_user(user_id)
|
||||
if user.tokens_valid_after is not None and _issued_at(token) < int(user.tokens_valid_after.timestamp()):
|
||||
raise AuthenticationFailed('از حساب خارج شدهاید؛ دوباره وارد شوید.')
|
||||
return (user, token)
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
170
accounts/tests_audit_fixes.py
Normal file
170
accounts/tests_audit_fixes.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
حساب، ورود و چند موردِ دیگر از بازبینیِ ۲۰۲۶-۰۹-۲۵.
|
||||
"""
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import OTPCode, User
|
||||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||||
from progress.models import UserHearts, UserStats
|
||||
|
||||
|
||||
def _client(user):
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
return client
|
||||
|
||||
|
||||
class LogoutTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_logout_kills_the_current_token_and_its_refresh(self):
|
||||
guest = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-out'},
|
||||
format='json').json()
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}')
|
||||
self.assertEqual(client.get('/api/user/profile').status_code, 200)
|
||||
|
||||
time.sleep(1.1) # زمانِ توکن ثانیهای است؛ توکن باید پیش از خروج صادر شده باشد
|
||||
response = client.post('/api/auth/logout', {'refreshToken': guest['refreshToken']}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertEqual(client.get('/api/user/profile').status_code, 401)
|
||||
refreshed = APIClient().post('/api/auth/refresh', {'refreshToken': guest['refreshToken']}, format='json')
|
||||
self.assertEqual(refreshed.status_code, 401)
|
||||
|
||||
# ورودِ دوباره کار میکند
|
||||
again = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-out'},
|
||||
format='json').json()
|
||||
fresh = APIClient()
|
||||
fresh.credentials(HTTP_AUTHORIZATION=f'Bearer {again["token"]}')
|
||||
self.assertEqual(fresh.get('/api/user/profile').status_code, 200)
|
||||
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
class OtpTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def _request(self, phone, code='123456'):
|
||||
with patch('accounts.views.secrets.randbelow', return_value=int(code)), \
|
||||
patch('accounts.views.send_otp_sms'):
|
||||
APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
|
||||
def test_a_code_dies_after_five_wrong_guesses(self):
|
||||
phone = '+989121234001'
|
||||
self._request(phone)
|
||||
for guess in ('000001', '000002', '000003', '000004', '000005'):
|
||||
APIClient().post('/api/auth/login', {'phone': phone, 'otp': guess}, format='json')
|
||||
self.assertTrue(OTPCode.objects.get(phone=phone).is_used)
|
||||
right = APIClient().post('/api/auth/login', {'phone': phone, 'otp': '123456'}, format='json')
|
||||
self.assertEqual(right.status_code, 400)
|
||||
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0,
|
||||
TEST_LOGIN_PHONES={'+989120000101': '4321'})
|
||||
class GuestSignupKeepsProgressTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
guest = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-up'},
|
||||
format='json').json()
|
||||
self.guest_id = int(guest['userId'])
|
||||
UserStats.objects.create(user_id=self.guest_id, xp=120)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}')
|
||||
|
||||
def _login(self, phone, otp):
|
||||
return self.client.post('/api/auth/login', {'phone': phone, 'otp': otp}, format='json')
|
||||
|
||||
def test_the_guest_becomes_the_registered_user(self):
|
||||
with patch('accounts.views.secrets.randbelow', return_value=654321), \
|
||||
patch('accounts.views.send_otp_sms'):
|
||||
self.client.post('/api/auth/otp/request', {'phone': '+989121234002'}, format='json')
|
||||
body = self._login('+989121234002', '654321').json()
|
||||
|
||||
self.assertEqual(int(body['userId']), self.guest_id)
|
||||
user = User.objects.get(pk=self.guest_id)
|
||||
self.assertEqual((user.phone, user.is_guest), ('+989121234002', False))
|
||||
self.assertEqual(UserStats.objects.get(user_id=self.guest_id).xp, 120)
|
||||
|
||||
def test_an_existing_account_is_not_merged(self):
|
||||
existing = User.objects.create(phone='+989121234003', is_guest=False)
|
||||
with patch('accounts.views.secrets.randbelow', return_value=111222), \
|
||||
patch('accounts.views.send_otp_sms'):
|
||||
self.client.post('/api/auth/otp/request', {'phone': '+989121234003'}, format='json')
|
||||
body = self._login('+989121234003', '111222').json()
|
||||
self.assertEqual(int(body['userId']), existing.id)
|
||||
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
|
||||
|
||||
def test_demo_test_phones_never_absorb_a_guest(self):
|
||||
body = self._login('+989120000101', '4321').json()
|
||||
self.assertNotEqual(int(body['userId']), self.guest_id)
|
||||
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
|
||||
|
||||
|
||||
class DisplayNameTests(TestCase):
|
||||
def test_contact_details_are_stripped_from_the_name(self):
|
||||
user = User.objects.create(phone='09121234004', is_guest=False)
|
||||
body = _client(user).patch('/api/user/profile', {'name': 'سارا 0912 345 6789 @sara'},
|
||||
format='json').json()
|
||||
self.assertEqual(body['name'], 'سارا')
|
||||
|
||||
|
||||
class HeartsTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='ف', order=1), title='ن', order=1)
|
||||
self.question = Question.objects.create(lesson=Lesson.objects.create(node=node, title='د'),
|
||||
type='multiple_choice', prompt='؟')
|
||||
self.right = Choice.objects.create(question=self.question, value='۱', is_correct=True)
|
||||
self.user = User.objects.create(phone='09121234005', is_guest=False)
|
||||
UserHearts.objects.create(user_id=self.user.id, current_hearts=0)
|
||||
|
||||
def _answer(self):
|
||||
return _client(self.user).post(f'/api/questions/{self.question.id}/answer',
|
||||
{'selectedOptionId': self.right.id}, format='json')
|
||||
|
||||
def test_off_by_default_so_the_current_app_keeps_working(self):
|
||||
self.assertEqual(self._answer().status_code, 200)
|
||||
|
||||
@override_settings(HEARTS_ENFORCED=True)
|
||||
def test_when_enforced_no_hearts_means_no_answer(self):
|
||||
response = self._answer()
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()['code'], 'no_hearts')
|
||||
|
||||
|
||||
class FamilyTwoChildrenTests(TestCase):
|
||||
def test_a_parent_of_two_can_see_each_child(self):
|
||||
parent = User.objects.create(phone='09121234010', is_guest=False)
|
||||
kids = []
|
||||
for phone in ('09121234011', '09121234012'):
|
||||
kid = User.objects.create(phone=phone, is_guest=False)
|
||||
code = _client(parent).post('/api/family/link/invite', {'childPhone': phone},
|
||||
format='json').json()['inviteCode']
|
||||
_client(kid).post('/api/family/link/accept', {'inviteCode': code}, format='json')
|
||||
kids.append(kid)
|
||||
client = _client(parent)
|
||||
self.assertEqual(client.get('/api/family/settings').status_code, 200)
|
||||
self.assertEqual(client.get('/api/family/weekly-report').status_code, 200)
|
||||
self.assertEqual(client.get(f'/api/family/settings?childUserId={kids[0].id}').status_code, 200)
|
||||
self.assertEqual(client.get('/api/family/settings?childUserId=999999').status_code, 404)
|
||||
|
||||
|
||||
class AdminLoginThrottleTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
@override_settings(ADMIN_LOGIN_MAX_ATTEMPTS=3)
|
||||
def test_password_guessing_is_capped(self):
|
||||
codes = [self.client.post('/admin/login/', {'username': 'a', 'password': 'b'}).status_code
|
||||
for _ in range(4)]
|
||||
self.assertEqual(codes[-1], 429)
|
||||
self.assertNotEqual(codes[0], 429)
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
@@ -14,7 +15,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .authentication import issue_access_token
|
||||
from .authentication import invalidate_user_cache, issue_access_token
|
||||
from .models import OTPCode, Profile, Session, User
|
||||
from .services import OTPDeliveryError, send_otp_sms
|
||||
from .serializers import (
|
||||
@@ -22,6 +23,8 @@ from .serializers import (
|
||||
GuestSessionResponseSerializer,
|
||||
LoginRequestSerializer,
|
||||
LoginResponseSerializer,
|
||||
LogoutRequestSerializer,
|
||||
LogoutResponseSerializer,
|
||||
ProfileResponseSerializer,
|
||||
RefreshRequestSerializer,
|
||||
RefreshResponseSerializer,
|
||||
@@ -207,7 +210,9 @@ class RequestOTPView(APIView):
|
||||
# فقط آخرین کد صادرشده معتبر است.
|
||||
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
||||
|
||||
code = f'{secrets.randbelow(10000):04d}'
|
||||
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
|
||||
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را میپذیرد.
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
OTPCode.objects.create(
|
||||
phone=phone,
|
||||
code=code,
|
||||
@@ -300,8 +305,20 @@ class LoginView(APIView):
|
||||
valid = True
|
||||
|
||||
if not valid:
|
||||
_count_failed_attempt(phone)
|
||||
return Response({'detail': 'کد OTP نامعتبر یا منقضیشده است.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user = User.objects.filter(phone=phone).first()
|
||||
created = False
|
||||
guest = _upgradable_guest(request, phone) if user is None else None
|
||||
if guest is not None:
|
||||
# مهمانی که ثبتنام میکند همان کاربر میماند: امتیاز، استریک،
|
||||
# جزیرهها و گفتگوها همه با همین user_id ذخیره شدهاند. پیشتر یک
|
||||
# کاربرِ تازه و خالی ساخته میشد و پیشرفتِ مهمان جا میماند.
|
||||
guest.phone = phone
|
||||
guest.save(update_fields=['phone'])
|
||||
user, created = guest, True
|
||||
elif user is None:
|
||||
user, created = User.objects.get_or_create(
|
||||
phone=phone,
|
||||
defaults={'is_guest': False, 'name': ''},
|
||||
@@ -336,6 +353,58 @@ class LoginView(APIView):
|
||||
return Response(LoginResponseSerializer(data).data)
|
||||
|
||||
|
||||
def _count_failed_attempt(phone: str) -> None:
|
||||
"""حدسِ غلط روی کدهای فعالِ این شماره؛ کدی که به سقف برسد باطل میشود."""
|
||||
active = OTPCode.objects.filter(phone=phone, is_used=False, expires_at__gte=timezone.now())
|
||||
active.update(failed_attempts=F('failed_attempts') + 1)
|
||||
active.filter(failed_attempts__gte=settings.OTP_MAX_FAILED_ATTEMPTS).update(is_used=True)
|
||||
|
||||
|
||||
def _upgradable_guest(request, phone: str):
|
||||
"""
|
||||
مهمانی که با توکنِ خودش درخواستِ ورود داده و این شماره هنوز حسابی ندارد.
|
||||
|
||||
شمارههای TEST_LOGIN_PHONES عمداً کنار گذاشته میشوند: حسابهای دمو با
|
||||
همین شمارهها ساخته شدهاند و ورودِ آزمایشی نباید حسابِ مهمانِ دمو
|
||||
(`frontend-test-device`) را به یک شماره تبدیل کند.
|
||||
"""
|
||||
if phone in settings.TEST_LOGIN_PHONES:
|
||||
return None
|
||||
user = getattr(request, 'user', None)
|
||||
if user is None or not getattr(user, 'is_authenticated', False) or not user.is_guest:
|
||||
return None
|
||||
return User.objects.filter(pk=user.pk, is_guest=True, phone__isnull=True).first()
|
||||
|
||||
|
||||
class LogoutView(APIView):
|
||||
"""
|
||||
POST /api/auth/logout — خروج از حساب.
|
||||
|
||||
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل میشود و همهی access
|
||||
tokenهای صادرشده تا این لحظه رد میشوند. دستگاههای دیگر با refreshِ
|
||||
خودشان بیصدا توکنِ تازه میگیرند؛ فقط همین دستگاه بیرون میماند.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
request=LogoutRequestSerializer, responses=LogoutResponseSerializer,
|
||||
tags=['Identity & Profile (accounts)'],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = LogoutRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
refresh_token = serializer.validated_data.get('refreshToken') or ''
|
||||
with transaction.atomic():
|
||||
if refresh_token:
|
||||
Session.objects.filter(
|
||||
user_id=request.user.id, refresh_token=refresh_token,
|
||||
).update(revoked=True)
|
||||
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
|
||||
invalidate_user_cache(request.user.id)
|
||||
return Response(LogoutResponseSerializer({'success': True}).data)
|
||||
|
||||
|
||||
class RefreshTokenView(APIView):
|
||||
"""POST /api/auth/refresh — تمدید توکن"""
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ from rest_framework import serializers
|
||||
class AffiliateMeResponseSerializer(serializers.Serializer):
|
||||
referralCode = serializers.CharField()
|
||||
commissionRate = serializers.FloatField()
|
||||
referredCount = serializers.IntegerField(help_text='کاربرهایی که کدِ شما را ثبت کردهاند.')
|
||||
paidCount = serializers.IntegerField(help_text='از میانِ آنها، کسانی که واقعاً پرداخت کردهاند.')
|
||||
totalCommission = serializers.IntegerField(help_text='جمعِ پورسانتِ پرداختهای واقعی (تومان).')
|
||||
|
||||
|
||||
class TrackRequestSerializer(serializers.Serializer):
|
||||
|
||||
43
affiliate/services.py
Normal file
43
affiliate/services.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
پورسانتِ معرف: فقط روی پرداختِ واقعی، فقط یک بار برای هر کاربرِ معرفیشده.
|
||||
|
||||
تا ۲۰۲۶-۰۹-۲۵ هیچ کدی Conversion را از «pending» جلو نمیبرد و پورسانتی
|
||||
حساب نمیشد؛ برنامهی معرفی فقط یک جدولِ ثبت بود. حالا اولین سفارشِ
|
||||
پرداختشدهی کاربرِ معرفیشده Conversion را `paid` میکند و
|
||||
`commission_amount = مبلغِ پرداختی × commission_rate` را ثبت میکند.
|
||||
پرداختِ خودِ پورسانت به معرف (تسویه) هنوز دستی و از پنلِ ادمین است.
|
||||
|
||||
ثبتنام یا ورودِ مهمان هیچ پورسانتی نمیسازد؛ فقط پول جابهجا شدنِ واقعی.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from .models import Conversion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def record_paid_order(order) -> Conversion | None:
|
||||
"""از apply_order_payment_result صدا زده میشود، داخلِ همان تراکنش."""
|
||||
if not order.amount:
|
||||
return None
|
||||
try:
|
||||
with transaction.atomic():
|
||||
conversion = (
|
||||
Conversion.objects.select_for_update()
|
||||
.select_related('affiliate_account')
|
||||
.filter(referred_user_id=order.user_id, status='pending')
|
||||
.first()
|
||||
)
|
||||
if conversion is None:
|
||||
return None
|
||||
rate = conversion.affiliate_account.commission_rate or 0
|
||||
conversion.status = 'paid'
|
||||
conversion.order_id = order.id
|
||||
conversion.commission_amount = int(order.amount * rate)
|
||||
conversion.save(update_fields=['status', 'order_id', 'commission_amount'])
|
||||
return conversion
|
||||
except Exception: # noqa: BLE001 — پورسانت نباید تاییدِ پرداختِ خودِ کاربر را خراب کند
|
||||
logger.exception('affiliate: ثبتِ پورسانتِ سفارش %s شکست خورد', order.id)
|
||||
return None
|
||||
@@ -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
|
||||
|
||||
|
||||
65
ai_tutor/provider_status.py
Normal file
65
ai_tutor/provider_status.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
وضعیتِ provider هوش مصنوعی (GapGPT): «کار میکند» یا «اعتبار تمام شده».
|
||||
|
||||
چرا لازم است: ۲۰۲۶-۰۹-۲۵ اعتبارِ GapGPT تمام شد و دستیار برای همهی کاربرها
|
||||
۵۰۳ داد («دستیار الان در دسترس نیست»)، در حالی که /api/health همچنان
|
||||
`openai: true` نشان میداد — چون فقط «کلید تنظیم شده» را میدید. تنها راهِ
|
||||
فهمیدنش خواندنِ لاگِ سرور بود.
|
||||
|
||||
حالا هر گذار (ok → quota_exhausted و برعکس) یک ردیف در statuslog میشود:
|
||||
همهی workerها آن را میبینند (کشِ LocMem اینطور نیست) و /api/health
|
||||
گزارشش میکند. فقط گذار ثبت میشود، نه هر درخواست.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LABEL = 'ai_tutor.Provider'
|
||||
OK = 'ok'
|
||||
EXHAUSTED = 'quota_exhausted'
|
||||
_CACHE_KEY = 'ai_tutor:provider_state'
|
||||
|
||||
|
||||
def current_state() -> str:
|
||||
state = cache.get(_CACHE_KEY)
|
||||
if state is None:
|
||||
from statuslog.models import StatusChangeLog
|
||||
|
||||
state = (
|
||||
StatusChangeLog.objects.filter(label=LABEL)
|
||||
.values_list('new_status', flat=True).first()
|
||||
) or OK
|
||||
cache.set(_CACHE_KEY, state, 60)
|
||||
return state
|
||||
|
||||
|
||||
def _transition(new_state: str, event: str) -> None:
|
||||
from statuslog.signals import status_changed
|
||||
|
||||
old_state = current_state()
|
||||
if old_state == new_state:
|
||||
return
|
||||
status_changed.send(sender=LABEL, object_id=0, old_status=old_state,
|
||||
new_status=new_state, event=event)
|
||||
cache.set(_CACHE_KEY, new_state, 60)
|
||||
|
||||
|
||||
def record_failure(exc) -> None:
|
||||
"""خطای provider؛ فقط تمامشدنِ اعتبار/سقفِ حساب وضعیت را عوض میکند."""
|
||||
from rag.services.clients import is_account_limit
|
||||
|
||||
try:
|
||||
if is_account_limit(exc):
|
||||
_transition(EXHAUSTED, 'provider_account_limit')
|
||||
except Exception: # noqa: BLE001 — گزارشدهی نباید پاسخِ ۵۰۳ را خراب کند
|
||||
logger.exception('provider_status: ثبتِ خطا شکست خورد')
|
||||
|
||||
|
||||
def record_success() -> None:
|
||||
try:
|
||||
if current_state() != OK:
|
||||
_transition(OK, 'provider_recovered')
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception('provider_status: ثبتِ بازگشت شکست خورد')
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from accounts.models import User as AccountUser
|
||||
from . import provider_status
|
||||
from .models import AskIdempotency, Conversation, Message, MessageFeedback
|
||||
from .serializers import (
|
||||
AskRequestSerializer,
|
||||
@@ -103,6 +104,43 @@ def _is_db_ref_id(value) -> bool:
|
||||
return str(value).strip().lstrip('-').isdigit()
|
||||
|
||||
|
||||
def _conversation_history(conversation, before_message) -> list[dict]:
|
||||
"""
|
||||
چند پیامِ آخرِ همین گفتگو، پیش از سوالِ تازه، به ترتیبِ زمان.
|
||||
|
||||
بدونِ این، هر سوال جدا از قبلیها جواب داده میشد: «نفهمیدم، سادهتر بگو»
|
||||
برای مدل هیچ مرجعی نداشت.
|
||||
"""
|
||||
from rag.services.generation import HISTORY_TURNS
|
||||
|
||||
rows = list(
|
||||
Message.objects.filter(conversation=conversation, id__lt=before_message.id)
|
||||
.order_by('-id').values('role', 'text')[:HISTORY_TURNS]
|
||||
)
|
||||
return list(reversed(rows))
|
||||
|
||||
|
||||
def _retrieval_query(question: str, history: list[dict], focus: str = '') -> str:
|
||||
"""
|
||||
متنی که embedding میشود. «یه مثال دیگه بزن» بهتنهایی به هیچ تکهای از
|
||||
کتاب نزدیک نیست؛ سوالِ قبلیِ کاربر (یا سوالِ تمرینیِ اپ) موضوع را معلوم میکند.
|
||||
"""
|
||||
if focus:
|
||||
return f'{focus}\n{question}'
|
||||
previous = next((t['text'] for t in reversed(history) if t['role'] == 'user'), '')
|
||||
return f'{previous}\n{question}' if previous else question
|
||||
|
||||
|
||||
def _question_focus(v: dict, user) -> str:
|
||||
"""اگر اپ questionId فرستاده، متنِ همان سوال و جوابِ دانشآموز (rag.generation)."""
|
||||
question_id = v.get('questionId')
|
||||
if not _is_db_ref_id(question_id):
|
||||
return ''
|
||||
from rag.services.generation import question_focus
|
||||
|
||||
return question_focus(question_id, user.id)
|
||||
|
||||
|
||||
class AIProviderUnavailable(APIException):
|
||||
"""
|
||||
⚠️ افزوده شده: وقتی generate_tutor_answer به یک AI provider واقعی
|
||||
@@ -330,9 +368,12 @@ class AskAIView(APIView):
|
||||
from rag.services.generation import generate_answer
|
||||
from rag.services.retrieval import resolve_target, retrieve_context
|
||||
|
||||
history = _conversation_history(conversation, user_message)
|
||||
focus = _question_focus(v, user)
|
||||
cacheable = answer_cache.cacheable(history) and not focus
|
||||
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
||||
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
|
||||
cached = answer_cache.get(cache_key)
|
||||
cached = answer_cache.get(cache_key) if cacheable else None
|
||||
if cached is not None:
|
||||
answer_text = cached['answer']
|
||||
follow_ups = list(cached['followUps'])
|
||||
@@ -342,7 +383,8 @@ class AskAIView(APIView):
|
||||
answer_from_cache = True
|
||||
else:
|
||||
used_chunks, retrieval_meta = retrieve_context(
|
||||
v['userQuestion'], subject=subject, grade_id=grade_id,
|
||||
_retrieval_query(v['userQuestion'], history, focus),
|
||||
subject=subject, grade_id=grade_id,
|
||||
grade_title=grade_title, lesson_number=lesson_number,
|
||||
)
|
||||
used_chunks = [
|
||||
@@ -352,7 +394,10 @@ class AskAIView(APIView):
|
||||
answer_text, follow_ups, generation_meta = generate_answer(
|
||||
v['userQuestion'], used_chunks or None,
|
||||
retrieval_meta.get('gradeTitle') or grade_title or '',
|
||||
history=history, focus=focus,
|
||||
)
|
||||
provider_status.record_success()
|
||||
if cacheable:
|
||||
answer_cache.set(cache_key, {
|
||||
'answer': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
@@ -362,8 +407,9 @@ class AskAIView(APIView):
|
||||
answer_text, follow_ups = generate_tutor_answer(
|
||||
v['userQuestion'], grade_id, lesson_id, context
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception('generate_tutor_answer failed for user_id=%s', user.id)
|
||||
provider_status.record_failure(exc)
|
||||
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||||
raise AIProviderUnavailable()
|
||||
|
||||
@@ -436,7 +482,9 @@ class AskAIView(APIView):
|
||||
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
|
||||
"""
|
||||
from rag.services import answer_cache
|
||||
from rag.services.generation import PROMPT_VERSION, default_follow_ups, stream_answer
|
||||
from rag.services.generation import (
|
||||
PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer,
|
||||
)
|
||||
from rag.services.retrieval import resolve_target, retrieve_context
|
||||
|
||||
def _sse(event: str, payload: dict) -> str:
|
||||
@@ -449,7 +497,10 @@ class AskAIView(APIView):
|
||||
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
||||
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
|
||||
try:
|
||||
cached = answer_cache.get(cache_key)
|
||||
history = _conversation_history(conversation, user_message)
|
||||
focus = _question_focus(v, user)
|
||||
cacheable = answer_cache.cacheable(history) and not focus
|
||||
cached = answer_cache.get(cache_key) if cacheable else None
|
||||
if cached is not None:
|
||||
answer_text = cached['answer']
|
||||
follow_ups = list(cached['followUps'])
|
||||
@@ -460,7 +511,8 @@ class AskAIView(APIView):
|
||||
yield _sse('chunk', {'delta': answer_text})
|
||||
else:
|
||||
used_chunks, retrieval_meta = retrieve_context(
|
||||
v['userQuestion'], subject=subject, grade_id=grade_id,
|
||||
_retrieval_query(v['userQuestion'], history, focus),
|
||||
subject=subject, grade_id=grade_id,
|
||||
grade_title=grade_title, lesson_number=lesson_number,
|
||||
)
|
||||
used_chunks = [
|
||||
@@ -469,30 +521,34 @@ class AskAIView(APIView):
|
||||
]
|
||||
parts: list[str] = []
|
||||
started = time.monotonic()
|
||||
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
|
||||
for delta in stream_answer(
|
||||
v['userQuestion'], used_chunks or None,
|
||||
retrieval_meta.get('gradeTitle') or grade_title or '',
|
||||
v['userQuestion'], used_chunks or None, effective_grade,
|
||||
history=history, focus=focus,
|
||||
):
|
||||
parts.append(delta)
|
||||
yield _sse('chunk', {'delta': delta})
|
||||
provider_status.record_success()
|
||||
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||||
follow_ups = default_follow_ups()
|
||||
# مسیرِ استریم usage برنمیگرداند (پاسخ توکنبهتوکن است)،
|
||||
# پس فقط چیزی را ثبت میکنیم که واقعاً میدانیم؛ توکنها صفر
|
||||
# میمانند و گزارشِ هزینه هم همین را نشان میدهد.
|
||||
generation_meta = {
|
||||
'model': settings.OPENAI_CHAT_MODEL,
|
||||
'model': chat_model_for(effective_grade),
|
||||
'promptVersion': PROMPT_VERSION,
|
||||
'latencyMs': int((time.monotonic() - started) * 1000),
|
||||
'hadContext': bool(used_chunks),
|
||||
}
|
||||
if cacheable:
|
||||
answer_cache.set(cache_key, {
|
||||
'answer': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
'generationMeta': generation_meta,
|
||||
})
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception('stream generate failed for user_id=%s', user.id)
|
||||
provider_status.record_failure(exc)
|
||||
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||||
yield _sse('error', {'detail': AIProviderUnavailable.default_detail})
|
||||
return
|
||||
|
||||
@@ -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ها مشترک نیست.')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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('_', ''))
|
||||
|
||||
|
||||
96
content/tests_island_progress.py
Normal file
96
content/tests_island_progress.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
جزیرهها روی نقشه باید با بازی کردنِ درسها واقعاً تمام شوند (۲۰۲۶-۰۹-۲۵).
|
||||
|
||||
دو چیز با هم جلوی پیشرفت را گرفته بود:
|
||||
۱. جزیره فقط وقتی تمام میشد که *همهی* درسهایش تمام شده باشند، ولی ۵۸۵ درس
|
||||
هنوز سوال ندارند و اپ برایشان دکمهی شروع ندارد — ۱۸۱ جزیره از ۲۶۹.
|
||||
۲. فرانت با کلیک روی جزیره همیشه lessonIds[0] را باز میکرد، یعنی درسِ اول
|
||||
را بارها و بارها.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
|
||||
from .models import Choice, Grade, Lesson, Node, Question, Unit
|
||||
|
||||
|
||||
class IslandProgressTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
self.first = Node.objects.create(unit=unit, title='جزیرهی اول', order=1)
|
||||
self.second = Node.objects.create(unit=unit, title='جزیرهی دوم', order=2)
|
||||
self.a = self._lesson(self.first, 'الف', with_question=True)
|
||||
self.empty = self._lesson(self.first, 'بیسوال', with_question=False)
|
||||
self.b = self._lesson(self.first, 'ب', with_question=True)
|
||||
self._lesson(self.second, 'ج', with_question=True)
|
||||
|
||||
user = User.objects.create(phone='09120000950', grade_id=grade.id)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def _lesson(self, node, title, *, with_question):
|
||||
lesson = Lesson.objects.create(node=node, title=title, intro_text='x', mascot_animation='x')
|
||||
if with_question:
|
||||
self._question(lesson)
|
||||
return lesson
|
||||
|
||||
def _question(self, lesson):
|
||||
question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
|
||||
Choice.objects.create(question=question, value='۱', is_correct=True)
|
||||
Choice.objects.create(question=question, value='۲', is_correct=False)
|
||||
|
||||
def _nodes(self):
|
||||
units = self.client.get('/api/home').json()['units']
|
||||
return {n['title']: n for n in units[0]['nodes']}
|
||||
|
||||
def _complete(self, lesson):
|
||||
response = self.client.post(f'/api/lessons/{lesson.id}/complete',
|
||||
{'correctCount': 1, 'totalQuestions': 1}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
|
||||
def test_tapping_the_island_opens_the_next_unfinished_lesson(self):
|
||||
nodes = self._nodes()
|
||||
self.assertEqual(nodes['جزیرهی اول']['nextLessonId'], self.a.id)
|
||||
self.assertEqual(nodes['جزیرهی اول']['lessonIds'][0], self.a.id)
|
||||
|
||||
self._complete(self.a)
|
||||
first = self._nodes()['جزیرهی اول']
|
||||
# درسِ بیسوال رد میشود؛ lessonIds[0] همان است که فرانت باز میکند.
|
||||
self.assertEqual(first['nextLessonId'], self.b.id)
|
||||
self.assertEqual(first['lessonIds'][0], self.b.id)
|
||||
self.assertCountEqual(first['lessonIds'], [self.a.id, self.empty.id, self.b.id])
|
||||
|
||||
def test_a_lesson_without_questions_does_not_hold_the_island(self):
|
||||
self._complete(self.a)
|
||||
self._complete(self.b)
|
||||
nodes = self._nodes()
|
||||
self.assertEqual(nodes['جزیرهی اول']['status'], 'completed')
|
||||
self.assertEqual(nodes['جزیرهی دوم']['status'], 'unlocked')
|
||||
# همهی درسها تمام شدهاند: برای مرور، درسِ اول.
|
||||
self.assertEqual(nodes['جزیرهی اول']['nextLessonId'], self.a.id)
|
||||
|
||||
def test_progress_counts_only_lessons_that_can_be_played(self):
|
||||
self._complete(self.a)
|
||||
percent = self.client.get('/api/home').json()['stats']['progressPercent']
|
||||
self.assertAlmostEqual(percent, 33.33, places=2) # ۱ از ۳ درسِ سوالدار
|
||||
|
||||
def test_new_content_is_offered_but_progress_never_goes_backwards(self):
|
||||
self._complete(self.a)
|
||||
self._complete(self.b)
|
||||
self.assertEqual(self._nodes()['جزیرهی اول']['status'], 'completed')
|
||||
|
||||
self._question(self.empty) # حالا این درس هم بازیشدنی است
|
||||
nodes = self._nodes()
|
||||
# نقشهی کششده باطل شد: درسِ تازه پیشنهاد میشود…
|
||||
self.assertEqual(nodes['جزیرهی اول']['nextLessonId'], self.empty.id)
|
||||
# …ولی جزیرهی تمامشده باز و جزیرهی بعدی قفل نمیشود.
|
||||
self.assertEqual(nodes['جزیرهی اول']['status'], 'completed')
|
||||
self.assertEqual(nodes['جزیرهی دوم']['status'], 'unlocked')
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 را از شمارهاش میگیرد، نه از شمارندهی
|
||||
دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده میشود. پایهی
|
||||
|
||||
@@ -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` توکنهای ورود را امضا میکند.** بهترین حالت این است که
|
||||
> اصلاً ست نشود تا خودِ تنظیمات یکی بسازد — آنوقت کلیدی وجود ندارد که لو
|
||||
|
||||
@@ -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():
|
||||
|
||||
0
payment/management/__init__.py
Normal file
0
payment/management/__init__.py
Normal file
0
payment/management/commands/__init__.py
Normal file
0
payment/management/commands/__init__.py
Normal file
28
payment/management/commands/payment_reconcile.py
Normal file
28
payment/management/commands/payment_reconcile.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
سفارشهای pendingِ زرینپال را دوباره verify میکند.
|
||||
|
||||
همین کار برای هر کاربر خودکار هم انجام میشود (وقتی اپ وضعیتِ اشتراک یا
|
||||
سفارش را میپرسد)؛ این فرمان برای وقتی است که بخواهید همه را یکجا ببینید:
|
||||
|
||||
python manage.py payment_reconcile --days 3
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from payment.models import Order
|
||||
from payment.services import reconcile_pending_order
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'verifyِ دوبارهی سفارشهای pendingِ زرینپال'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--days', type=int, default=3)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
since = timezone.now() - timezone.timedelta(days=opts['days'])
|
||||
orders = list(Order.objects.filter(
|
||||
status='pending', provider='gateway', created_at__gte=since,
|
||||
).exclude(gateway_authority=''))
|
||||
fixed = sum(1 for order in orders if reconcile_pending_order(order))
|
||||
self.stdout.write(f'{len(orders)} سفارشِ pending بررسی شد؛ {fixed} سفارش paid شد.')
|
||||
18
payment/migrations/0007_order_plan_id.py
Normal file
18
payment/migrations/0007_order_plan_id.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-25 02:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payment', '0006_order_gateway_authority'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='plan_id',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمیگردونیم تا روی
|
||||
# بقیهی تستها اثر نذاره.
|
||||
|
||||
167
payment/tests_audit_fixes.py
Normal file
167
payment/tests_audit_fixes.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
پول: ایرادهایی که بازبینیِ ۲۰۲۶-۰۹-۲۵ بازتولید کرد و حالا بسته شدهاند.
|
||||
|
||||
هر تست همان سناریوی واقعی را اجرا میکند که پیشتر خراب بود.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from affiliate.models import AffiliateAccount, Conversion
|
||||
from discount.models import DiscountCode
|
||||
from payment.models import Order
|
||||
from payment.services import apply_order_payment_result, reconcile_pending_order
|
||||
from payment.zarinpal import ZarinpalVerifyResult
|
||||
from subscription.models import Plan, Subscription
|
||||
|
||||
|
||||
class _Base(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120007001', is_guest=False)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
self.month = Plan.objects.create(title='ماهانه', price=49000, duration_days=30)
|
||||
self.year = Plan.objects.create(title='سالانه', price=299000, duration_days=365)
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def buy(self, plan):
|
||||
response = self.client.post('/api/subscription/purchase', {'planId': str(plan.id)}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
return Order.objects.get(pk=response.json()['orderId'])
|
||||
|
||||
def pay(self, order, ref='R'):
|
||||
return apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref,
|
||||
new_status='paid', event='test')
|
||||
|
||||
def subscription(self):
|
||||
return Subscription.objects.get(user_id=self.user.id)
|
||||
|
||||
|
||||
class CheckoutTests(_Base):
|
||||
def test_paying_the_cheap_order_gives_the_cheap_plan(self):
|
||||
cheap = self.buy(self.month)
|
||||
self.buy(self.year) # شروعِ خریدِ دوم، بدونِ پرداخت
|
||||
self.pay(cheap)
|
||||
|
||||
sub = self.subscription()
|
||||
self.assertEqual(sub.plan, self.month)
|
||||
days = (sub.expires_at - timezone.now()).days
|
||||
self.assertIn(days, (29, 30))
|
||||
|
||||
def test_opening_checkout_does_not_take_away_an_active_subscription(self):
|
||||
self.pay(self.buy(self.month), ref='R1')
|
||||
self.buy(self.year)
|
||||
self.assertEqual(self.subscription().status, 'active')
|
||||
self.assertTrue(self.client.get('/api/subscription/status').json()['active'])
|
||||
|
||||
def test_renewing_early_keeps_the_remaining_days(self):
|
||||
self.pay(self.buy(self.month), ref='R1')
|
||||
first_expiry = self.subscription().expires_at
|
||||
self.pay(self.buy(self.month), ref='R2')
|
||||
self.assertEqual((self.subscription().expires_at - first_expiry).days, 30)
|
||||
|
||||
def test_an_expired_subscription_is_not_reported_active(self):
|
||||
self.pay(self.buy(self.month), ref='R1')
|
||||
Subscription.objects.filter(user_id=self.user.id).update(
|
||||
expires_at=timezone.now() - timezone.timedelta(days=1))
|
||||
self.assertFalse(self.client.get('/api/subscription/status').json()['active'])
|
||||
|
||||
def test_the_mark_paid_webhook_is_gone(self):
|
||||
order = self.buy(self.month)
|
||||
response = self.client.post('/api/payment/webhook', {'orderId': order.id, 'status': 'paid'},
|
||||
format='json', HTTP_X_WEBHOOK_SECRET='anything')
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class DiscountTests(_Base):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.half = DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
|
||||
self.tenth = DiscountCode.objects.create(code='TENTH', type='percent', value=10, max_uses=10)
|
||||
self.free = DiscountCode.objects.create(code='FREE', type='percent', value=100, max_uses=10)
|
||||
|
||||
def redeem(self, order, code):
|
||||
return self.client.post('/api/discount/redeem', {'orderId': str(order.id), 'code': code}, format='json')
|
||||
|
||||
def test_one_code_per_order(self):
|
||||
order = self.buy(self.month)
|
||||
self.assertEqual(self.redeem(order, 'HALF').status_code, 200)
|
||||
self.assertEqual(self.redeem(order, 'TENTH').status_code, 400)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.amount, 24500)
|
||||
|
||||
def test_no_discount_after_going_to_the_gateway(self):
|
||||
order = self.buy(self.month)
|
||||
Order.objects.filter(pk=order.pk).update(gateway_authority='A0000000000000000000000000000001')
|
||||
self.assertEqual(self.redeem(order, 'HALF').status_code, 400)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.amount, 49000)
|
||||
|
||||
def test_a_full_discount_completes_the_purchase_without_a_gateway(self):
|
||||
order = self.buy(self.month)
|
||||
body = self.redeem(order, 'FREE').json()
|
||||
self.assertEqual(body['orderStatus'], 'paid')
|
||||
self.assertEqual(body['amount'], 0)
|
||||
self.assertEqual(self.subscription().status, 'active')
|
||||
|
||||
|
||||
class ReconcileTests(_Base):
|
||||
def test_a_payment_whose_callback_was_lost_is_picked_up(self):
|
||||
order = self.buy(self.month)
|
||||
Order.objects.filter(pk=order.pk).update(gateway_authority='A1')
|
||||
order.refresh_from_db()
|
||||
with patch('payment.zarinpal.verify_payment',
|
||||
return_value=ZarinpalVerifyResult(is_valid=True, ref_id='REF9')):
|
||||
self.assertTrue(self.client.get('/api/subscription/status').json()['active'])
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, 'paid')
|
||||
|
||||
def test_an_unpaid_order_is_left_pending_not_failed(self):
|
||||
order = self.buy(self.month)
|
||||
Order.objects.filter(pk=order.pk).update(gateway_authority='A2')
|
||||
order.refresh_from_db()
|
||||
with patch('payment.zarinpal.verify_payment', return_value=ZarinpalVerifyResult(is_valid=False)):
|
||||
self.assertFalse(reconcile_pending_order(order))
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, 'pending')
|
||||
|
||||
|
||||
class AffiliateTests(_Base):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.referrer = User.objects.create(phone='09120007002', is_guest=False)
|
||||
self.account = AffiliateAccount.objects.create(user_id=self.referrer.id, referral_code='SARA10',
|
||||
commission_rate=0.1)
|
||||
|
||||
def test_commission_is_recorded_on_the_first_real_payment(self):
|
||||
self.assertEqual(self.client.post('/api/affiliate/track', {'referralCode': 'SARA10'},
|
||||
format='json').status_code, 200)
|
||||
self.pay(self.buy(self.month), ref='R1')
|
||||
conversion = Conversion.objects.get(referred_user_id=self.user.id)
|
||||
self.assertEqual(conversion.status, 'paid')
|
||||
self.assertEqual(conversion.commission_amount, 4900)
|
||||
|
||||
self.pay(self.buy(self.month), ref='R2') # فقط اولین پرداخت
|
||||
self.assertEqual(Conversion.objects.get(pk=conversion.pk).commission_amount, 4900)
|
||||
|
||||
referrer = APIClient()
|
||||
referrer.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.referrer.id)}')
|
||||
me = referrer.get('/api/affiliate/me').json()
|
||||
self.assertEqual((me['referredCount'], me['paidCount'], me['totalCommission']), (1, 1, 4900))
|
||||
|
||||
def test_guests_take_no_part(self):
|
||||
guest = User.objects.create(device_id='dev-aff', is_guest=True)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(guest.id)}')
|
||||
self.assertEqual(client.post('/api/affiliate/track', {'referralCode': 'SARA10'},
|
||||
format='json').status_code, 403)
|
||||
self.assertEqual(client.get('/api/affiliate/me').status_code, 403)
|
||||
self.assertFalse(Conversion.objects.exists())
|
||||
@@ -5,13 +5,11 @@ from .views import (
|
||||
CreateOrderView,
|
||||
OrderDetailView,
|
||||
PaymentRedirectView,
|
||||
PaymentWebhookView,
|
||||
ZarinpalCallbackView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
|
||||
path('payment/webhook', PaymentWebhookView.as_view(), name='payment-webhook'),
|
||||
path('payment/zarinpal/callback', ZarinpalCallbackView.as_view(), name='payment-zarinpal-callback'),
|
||||
path('payment/bazaar/verify', BazaarVerifyPurchaseView.as_view(), name='payment-bazaar-verify'),
|
||||
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
progress/migrations/0004_seyar_characters.py
Normal file
38
progress/migrations/0004_seyar_characters.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
سهیارِ اپ (سیگما، دلتا، پی) بهعنوانِ کاراکترهای واقعیِ کاتالوگ.
|
||||
|
||||
فرانت از ۲۰۲۶-۰۹-۲۴ صفحهی «همراهت رو انتخاب کن» دارد و فقط slugهای
|
||||
sigma/delta/pi را با طرحِ انیمیشندارِ خودش نشان میدهد؛ هر slugِ دیگری کارتِ
|
||||
عمومی میگیرد. تا امروز کاتالوگ روی سرور یا خالی بود یا فقط ردیفهای [SEED]
|
||||
داشت. اینها با get_or_create ساخته میشوند، پس اگر بعداً از ادمین
|
||||
ویرایش شوند (نام، امتیازِ باز شدن)، اجرای دوباره چیزی را برنمیگرداند.
|
||||
|
||||
امتیازِ باز شدن تصمیمِ محصولی است و از ادمین قابلِ تغییر است؛ ۱۰ امتیاز برای
|
||||
هر جوابِ درست، یعنی دلتا بعد از ~۱۰ و پی بعد از ~۳۰ جوابِ درست.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
SEYAR = [
|
||||
('sigma', 'سیگما', 0, 1),
|
||||
('delta', 'دلتا', 100, 2),
|
||||
('pi', 'پی', 300, 3),
|
||||
]
|
||||
|
||||
|
||||
def create(apps, schema_editor):
|
||||
Character = apps.get_model('progress', 'Character')
|
||||
for slug, name, unlock_xp, order in SEYAR:
|
||||
Character.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={'name': name, 'icon': slug, 'unlock_xp': unlock_xp, 'order': order},
|
||||
)
|
||||
|
||||
|
||||
def remove(apps, schema_editor):
|
||||
apps.get_model('progress', 'Character').objects.filter(
|
||||
slug__in=[s for s, *_ in SEYAR]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('progress', '0003_userhearts_full_refill_at')]
|
||||
operations = [migrations.RunPython(create, remove)]
|
||||
@@ -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},
|
||||
|
||||
341
rag/management/commands/rag_model_benchmark.py
Normal file
341
rag/management/commands/rag_model_benchmark.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
مقایسهی مدلهای چت برای دستیار («از پی بپرس») با عدد، نه با حدس.
|
||||
|
||||
چرا جدا از سنجشِ استخراجِ PDF: آنجا مدل صفحهی کتاب را میخواند (بینایی)،
|
||||
اینجا باید مسئله را درست حل کند و مرحلهبهمرحله توضیح دهد. مدلی که در یکی
|
||||
بهترین است لزوماً در دیگری نیست — gpt-4o-mini در استخراج مختصات جعل میکرد و
|
||||
در چت هم مشتقِ x³ − 6x² + 9x را غلط گرفت.
|
||||
|
||||
روش:
|
||||
۱. از هر کتاب چند مسئلهی بیشکل برمیدارد که `rag_verify_solutions` جوابِ
|
||||
کتاب را تأیید کرده (`agrees`) و جوابِ مستقلش (`checked_answer`) ثبت است؛
|
||||
همان جواب کلیدِ تصحیح است.
|
||||
۲. هر مسئله را از همان مسیرِ واقعیِ دستیار میگذراند: retrieval روی کلِ کتابِ
|
||||
آن پایه (مثلِ صفحهی «از پی بپرس») + همان پرامپتِ تولید. خودِ همان تکه از
|
||||
context حذف میشود تا مدل جواب را از کتاب رونویسی نکند.
|
||||
۳. یک مدلِ داورِ ثابت فقط جوابِ نهایی را با کلید مقایسه میکند.
|
||||
۴. هزینهی واقعی از `/dashboard/billing/usage` قبل و بعد از هر مدل خوانده
|
||||
میشود — قیمتِ GapGPT با جدولِ رسمی یکی نیست (docs/extraction-playbook.md).
|
||||
|
||||
# رایگان: فقط نمونه را نشان میدهد
|
||||
python manage.py rag_model_benchmark --dry-run
|
||||
|
||||
python manage.py rag_model_benchmark --models gpt-4o-mini gpt-4.1-mini o4-mini \\
|
||||
--per-grade 6 --json /tmp/bench.json
|
||||
|
||||
⚠️ GapGPT فقط از داخلِ ایران جواب میدهد؛ این را روی سرور اجرا کنید.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from rag.management.commands.rag_verify_solutions import (
|
||||
AccountLimitReached, is_account_limit, needs_figure,
|
||||
)
|
||||
from rag.models import LessonChunk
|
||||
from rag.services import clients
|
||||
from rag.services.config import rag_is_configured
|
||||
from rag.services.generation import completion_kwargs, generate_answer, grade_tier
|
||||
from rag.services.retrieval import retrieve_context
|
||||
|
||||
DEFAULT_MODELS = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'o4-mini']
|
||||
DEFAULT_JUDGE = 'gpt-4.1'
|
||||
|
||||
JUDGE_PROMPT = """یک مسئلهی ریاضیِ مدرسه، جوابِ درستِ آن (کلید)، و پاسخِ یک معلم به دانشآموز
|
||||
را میبینی. فقط بگو جوابِ نهاییِ معلم با کلید یکی است یا نه.
|
||||
|
||||
قواعد:
|
||||
- شکلِ نوشتن مهم نیست: 0.5 = ۱/۲ = نیم؛ «۳/۵» در کلید ممکن است ممیزِ اعشار باشد (۳٫۵).
|
||||
- ترتیب، واحدِ همارز، و گِرد کردنِ معقول خطا نیست.
|
||||
- اگر کلید چند بخش دارد، همهی بخشها باید درست باشند.
|
||||
- اگر معلم جوابِ نهایی نداده یا چند جوابِ متناقض داده، "wrong".
|
||||
- اگر خودِ کلید با مسئله نمیخواند یا مبهم است، "unclear".
|
||||
|
||||
مسئله:
|
||||
{problem}
|
||||
|
||||
کلید:
|
||||
{key}
|
||||
|
||||
پاسخِ معلم:
|
||||
{answer}
|
||||
|
||||
فقط JSON: {{"verdict": "correct|wrong|unclear", "why": "حداکثر ۸۰ نویسه"}}"""
|
||||
|
||||
# نشانهی LaTeX/markdown در خروجیِ *خامِ* مدل (پیش از تورِ ایمنی)
|
||||
_STEP_LINE = re.compile(r'^\s*([0-9۰-۹]+[)\.)]|مرحله)', re.M)
|
||||
|
||||
|
||||
_KEY_NOT_AN_ANSWER = re.compile(r'کتاب|اثبات|درست است|صحیح است|مطابق|توضیح')
|
||||
|
||||
|
||||
def _gradable(chunk) -> bool:
|
||||
"""
|
||||
فقط مسئلهای که جوابِ مشخص و کوتاه دارد (عدد، عبارت، مجموعه). کلیدهایی
|
||||
مثلِ «راهحلِ کتاب مطابقِ تعریف است» یا اثباتها را نمیشود با کلید سنجید.
|
||||
"""
|
||||
key = (chunk.checked_answer or '').strip()
|
||||
return (
|
||||
0 < len(key) <= 80
|
||||
and bool(re.search(r'[0-9۰-۹]', key))
|
||||
and not _KEY_NOT_AN_ANSWER.search(key)
|
||||
and not chunk.problem.strip().startswith(('اثبات', 'ثابت کنید', 'نشان دهید'))
|
||||
)
|
||||
|
||||
|
||||
def _stable_order(chunk_id: int, seed: str) -> str:
|
||||
return hashlib.sha1(f'{seed}:{chunk_id}'.encode()).hexdigest()
|
||||
|
||||
|
||||
def _grade_of(item) -> str:
|
||||
return (item['grade'] or item['collection'].split()[-1]).strip()
|
||||
|
||||
|
||||
def _usage_cents() -> float | None:
|
||||
"""مصرفِ تجمعیِ حساب به سنت، یا None اگر provider این endpoint را ندارد."""
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
|
||||
try:
|
||||
response = httpx.get(
|
||||
f'{settings.OPENAI_BASE_URL.rstrip("/")}/dashboard/billing/usage',
|
||||
headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, timeout=20,
|
||||
)
|
||||
data = response.json()
|
||||
return float(data['total_usage']) if 'total_usage' in data else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'مقایسهی دقت/هزینه/سرعتِ مدلهای چت روی مسئلههای تأییدشدهی کتاب'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--models', nargs='+', default=DEFAULT_MODELS)
|
||||
parser.add_argument('--judge', default=DEFAULT_JUDGE)
|
||||
parser.add_argument('--per-grade', type=int, default=6)
|
||||
parser.add_argument('--collections', nargs='*', default=None,
|
||||
help='پیشفرض: همهی کتابهای ریاضی/حسابان')
|
||||
parser.add_argument('--seed', default='bench-v1')
|
||||
parser.add_argument('--workers', type=int, default=4)
|
||||
parser.add_argument('--no-context', action='store_true',
|
||||
help='بدونِ retrieval — فقط توانِ ریاضیِ خودِ مدل')
|
||||
parser.add_argument('--json', dest='json_path', default=None)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
parser.add_argument('--resume', action='store_true',
|
||||
help='مدلهایی که در فایلِ --json تمام شدهاند دوباره اجرا نشوند')
|
||||
# LessonChunk (و کلیدِ checked_answer) فقط در دیتابیسِ محلی است، ولی GapGPT
|
||||
# فقط از ایران جواب میدهد: نمونه را اینجا بنویس، روی سرور بخوان.
|
||||
parser.add_argument('--export-sample', default=None, metavar='PATH')
|
||||
parser.add_argument('--sample-file', default=None, metavar='PATH')
|
||||
|
||||
# ------------------------------------------------------------------ sample
|
||||
def _sample(self, per_grade, collections, seed):
|
||||
qs = LessonChunk.objects.filter(solution_verdict='agrees').exclude(
|
||||
checked_answer='').exclude(problem='')
|
||||
if collections:
|
||||
qs = qs.filter(collection__in=collections)
|
||||
else:
|
||||
qs = qs.filter(collection__regex=r'^(ریاضی|حسابان) ')
|
||||
by_collection = defaultdict(list)
|
||||
for chunk in qs.only('id', 'problem', 'checked_answer', 'collection', 'grade_title',
|
||||
'subject', 'chapter'):
|
||||
if needs_figure(chunk.problem) or not _gradable(chunk):
|
||||
continue
|
||||
by_collection[chunk.collection].append(chunk)
|
||||
items = []
|
||||
for collection in sorted(by_collection):
|
||||
chunks = sorted(by_collection[collection], key=lambda c: _stable_order(c.id, seed))
|
||||
items.extend({
|
||||
'id': c.id, 'problem': c.problem, 'key': c.checked_answer,
|
||||
'collection': c.collection, 'grade': c.grade_title,
|
||||
} for c in chunks[:per_grade])
|
||||
return items
|
||||
|
||||
# ----------------------------------------------------------------- context
|
||||
def _context(self, item):
|
||||
subject = item['collection'].rsplit(' ', 1)[0]
|
||||
hits, _meta = retrieve_context(item['problem'], subject=subject, grade_title=_grade_of(item))
|
||||
needle = ' '.join(item['problem'].split())[:60]
|
||||
# همان تکه (و هر تکهای که عینِ این مسئله را دارد) بیرون: وگرنه مدل
|
||||
# فقط راهحلِ کتاب را رونویسی میکند و سنجه بیمعنا میشود.
|
||||
return [h for h in hits if needle not in ' '.join(h.get('summary', '').split())]
|
||||
|
||||
# ------------------------------------------------------------------ judge
|
||||
def _judge(self, judge_model, item, answer):
|
||||
client = clients.get_openai_client()
|
||||
prompt = JUDGE_PROMPT.format(problem=item['problem'], key=item['key'],
|
||||
answer=answer[-2500:])
|
||||
for attempt in range(3):
|
||||
try:
|
||||
completion = client.chat.completions.create(
|
||||
model=judge_model, messages=[{'role': 'user', 'content': prompt}],
|
||||
response_format={'type': 'json_object'}, **completion_kwargs(judge_model),
|
||||
)
|
||||
data = json.loads(completion.choices[0].message.content or '{}')
|
||||
verdict = data.get('verdict')
|
||||
if verdict in ('correct', 'wrong', 'unclear'):
|
||||
return verdict, str(data.get('why') or '')[:120]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if is_account_limit(exc):
|
||||
raise AccountLimitReached(str(exc)[:160]) from exc
|
||||
time.sleep(2 * (attempt + 1))
|
||||
return 'unclear', 'داوری شکست خورد'
|
||||
|
||||
# ------------------------------------------------------------------ handle
|
||||
def handle(self, *args, **opts):
|
||||
if opts['sample_file']:
|
||||
with open(opts['sample_file'], encoding='utf-8') as fh:
|
||||
items = json.load(fh)
|
||||
else:
|
||||
items = self._sample(opts['per_grade'], opts['collections'], opts['seed'])
|
||||
if opts['export_sample']:
|
||||
with open(opts['export_sample'], 'w', encoding='utf-8') as fh:
|
||||
json.dump(items, fh, ensure_ascii=False, indent=1)
|
||||
self.stdout.write(f'{len(items)} مسئله در {opts["export_sample"]} نوشته شد.')
|
||||
return
|
||||
if not items:
|
||||
raise CommandError('هیچ مسئلهی تأییدشدهای پیدا نشد.')
|
||||
|
||||
per_collection = defaultdict(int)
|
||||
for item in items:
|
||||
per_collection[item['collection']] += 1
|
||||
self.stdout.write(f'{len(items)} مسئله از {len(per_collection)} کتاب:')
|
||||
for collection, n in sorted(per_collection.items()):
|
||||
self.stdout.write(f' {collection:18s} {n}')
|
||||
calls = len(items) * len(opts['models'])
|
||||
self.stdout.write(f'{calls} پاسخ + {calls} داوری با {opts["judge"]}')
|
||||
if opts['dry_run']:
|
||||
for item in items[:3]:
|
||||
self.stdout.write(f'\n— {item["collection"]}: {item["problem"][:160]}\n کلید: {item["key"][:80]}')
|
||||
return
|
||||
if not rag_is_configured():
|
||||
raise CommandError('RAG پیکربندی نشده (OPENAI_API_KEY/QDRANT_API_KEY).')
|
||||
|
||||
# context یک بار برای همهی مدلها: مقایسه منصفانه و embedding ارزانتر.
|
||||
contexts = {}
|
||||
if not opts['no_context']:
|
||||
self.stdout.write('retrieval …')
|
||||
try:
|
||||
with ThreadPoolExecutor(opts['workers']) as pool:
|
||||
for item, ctx in zip(items, pool.map(self._context, items)):
|
||||
contexts[item['id']] = ctx
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if is_account_limit(exc):
|
||||
raise CommandError(f'⛔ اعتبارِ حساب تمام شده است: {str(exc)[:160]}') from exc
|
||||
raise
|
||||
|
||||
# نتیجهی هر مدل همان لحظه روی دیسک میرود. اجرای اول وسطِ کار با تمام
|
||||
# شدنِ اعتبار مرد و چون فقط آخرِ کار ذخیره میکرد، پاسخهای چهار مدلِ
|
||||
# پرداختشده از دست رفت. با --resume مدلهای تمامشده دوباره اجرا نمیشوند.
|
||||
results, summary = [], {}
|
||||
if opts['resume'] and opts['json_path']:
|
||||
try:
|
||||
with open(opts['json_path'], encoding='utf-8') as fh:
|
||||
saved = json.load(fh)
|
||||
summary = saved.get('summary', {})
|
||||
results = [r for r in saved.get('results', []) if r['model'] in summary]
|
||||
self.stdout.write(f'ادامه: {len(summary)} مدل از قبل — {", ".join(summary)}')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
chunks_by_id = {item['id']: item for item in items}
|
||||
try:
|
||||
for model in opts['models']:
|
||||
if model in summary:
|
||||
continue
|
||||
self._run_model(model, items, contexts, chunks_by_id, opts, results, summary)
|
||||
self._save(opts['json_path'], summary, results)
|
||||
except AccountLimitReached as exc:
|
||||
self._save(opts['json_path'], summary, results)
|
||||
self.stderr.write(self.style.ERROR(
|
||||
f'\n⛔ اعتبارِ حساب تمام شد ({exc}). نتیجهی مدلهای تمامشده ذخیره شد؛ '
|
||||
'بعد از شارژ با همان دستور و --resume ادامه بدهید.'))
|
||||
self._report([m for m in opts['models'] if m in summary], results, summary)
|
||||
|
||||
def _save(self, path, summary, results):
|
||||
if not path:
|
||||
return
|
||||
with open(path, 'w', encoding='utf-8') as fh:
|
||||
json.dump({'summary': summary, 'results': results}, fh, ensure_ascii=False, indent=1)
|
||||
|
||||
def _run_model(self, model, items, contexts, chunks_by_id, opts, results, summary):
|
||||
self.stdout.write(f'\n▶ {model}')
|
||||
before = _usage_cents()
|
||||
started = time.monotonic()
|
||||
|
||||
def run(item):
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
answer, _f, meta = generate_answer(
|
||||
item['problem'], contexts.get(item['id']) or None, _grade_of(item), model=model,
|
||||
)
|
||||
return item, answer, meta, time.monotonic() - t0, None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if is_account_limit(exc):
|
||||
raise AccountLimitReached(str(exc)[:160]) from exc
|
||||
return item, '', {}, time.monotonic() - t0, str(exc)[:200]
|
||||
|
||||
with ThreadPoolExecutor(opts['workers']) as pool:
|
||||
answered = list(pool.map(run, items))
|
||||
after = _usage_cents()
|
||||
wall = time.monotonic() - started
|
||||
cost = (after - before) if before is not None and after is not None else None
|
||||
|
||||
rows = []
|
||||
for item, answer, meta, seconds, error in answered:
|
||||
rows.append({
|
||||
'model': model, 'chunkId': item['id'], 'collection': item['collection'],
|
||||
'tier': grade_tier(_grade_of(item)), 'problem': item['problem'],
|
||||
'key': item['key'], 'answer': answer, 'seconds': round(seconds, 1),
|
||||
'sanitized': bool(meta.get('sanitized')),
|
||||
'completionTokens': meta.get('completionTokens', 0),
|
||||
'hasFinal': '✅' in answer, 'steps': len(_STEP_LINE.findall(answer)),
|
||||
'error': error, 'verdict': 'error', 'why': '',
|
||||
})
|
||||
errors = sum(1 for r in rows if r['error'])
|
||||
self.stdout.write(
|
||||
f' {len(rows) - errors} پاسخ، {errors} خطا، '
|
||||
f'{"هزینه: %.2f سنت" % cost if cost is not None else "هزینه: نامعلوم"}، {wall:.0f}s'
|
||||
)
|
||||
|
||||
todo = [r for r in rows if r['answer']]
|
||||
with ThreadPoolExecutor(opts['workers']) as pool:
|
||||
verdicts = list(pool.map(
|
||||
lambda r: self._judge(opts['judge'], chunks_by_id[r['chunkId']], r['answer']), todo))
|
||||
for r, (verdict, why) in zip(todo, verdicts):
|
||||
r['verdict'], r['why'] = verdict, why
|
||||
results.extend(rows)
|
||||
summary[model] = {'costCents': cost, 'wallSeconds': round(wall, 1)}
|
||||
|
||||
def _report(self, models, results, summary):
|
||||
tiers = ['primary', 'middle', 'upper']
|
||||
header = f'{"مدل":18s} {"درست":>7s} {"دبستان":>7s} {"متوسطه۱":>7s} {"متوسطه۲":>7s} ' \
|
||||
f'{"نامعلوم":>6s} {"سنت/پاسخ":>8s} {"ثانیه":>6s} {"LaTeX":>6s} {"✅":>5s}'
|
||||
self.stdout.write('\n' + header)
|
||||
for model in models:
|
||||
rows = [r for r in results if r['model'] == model]
|
||||
judged = [r for r in rows if r['verdict'] in ('correct', 'wrong')]
|
||||
|
||||
def pct(subset):
|
||||
graded = [r for r in subset if r['verdict'] in ('correct', 'wrong')]
|
||||
if not graded:
|
||||
return '—'
|
||||
return f'{100 * sum(r["verdict"] == "correct" for r in graded) / len(graded):.0f}%'
|
||||
|
||||
cost = summary[model]['costCents']
|
||||
per = f'{cost / len(rows):.3f}' if cost is not None and rows else '?'
|
||||
secs = sum(r['seconds'] for r in rows) / max(len(rows), 1)
|
||||
latex = sum(r['sanitized'] for r in rows) / max(len(rows), 1)
|
||||
final = sum(r['hasFinal'] for r in rows) / max(len(rows), 1)
|
||||
self.stdout.write(
|
||||
f'{model:18s} {pct(judged):>7s} '
|
||||
+ ' '.join(f'{pct([r for r in rows if r["tier"] == t]):>7s}' for t in tiers)
|
||||
+ f' {sum(r["verdict"] == "unclear" for r in rows):>6d} {per:>8s} {secs:>6.1f}'
|
||||
f' {100 * latex:>5.0f}% {100 * final:>4.0f}%'
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
# مسئلهای که به شکلِ کتاب ارجاع میدهد و محتوایش را ننوشته، بدونِ تصویر
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
"""
|
||||
مرحلهی generation: پرسش + context بازیابیشده → پاسخِ فارسی + چند پیشنهادِ
|
||||
ادامه.
|
||||
مرحلهی generation: پرسش + context بازیابیشده (+ چند پیامِ قبلیِ همان گفتگو)
|
||||
→ پاسخِ فارسی + چند پیشنهادِ ادامه.
|
||||
|
||||
اگه context خالی باشه، مدل بهعنوانِ معلمِ عمومی جواب میده (بدونِ ادعای
|
||||
استناد به کتاب) — این حالت وقتی پیش میآد که subject/lessonNumber ارسال
|
||||
نشده یا اون درس هنوز در Qdrant نیست.
|
||||
اگه context خالی باشه، مدل بهعنوانِ معلمِ همان پایه جواب میده (بدونِ ادعای
|
||||
استناد به کتاب) — این حالت وقتی پیش میآد که کتاب/فصل معلوم نیست یا اون درس
|
||||
هنوز در Qdrant نیست.
|
||||
|
||||
سه تصمیم که اینجا گرفته شده و دلیلشان:
|
||||
|
||||
1. **خروجی متنِ ساده است، نه LaTeX/markdown.** اپ پاسخ را خام نشان میدهد؛
|
||||
`\\frac{1}{2}` روی گوشی عیناً دیده میشد. پرامپت متنِ ساده میخواهد و
|
||||
`mathtext.to_plain` پشتش تورِ ایمنی است.
|
||||
2. **حل کامل و مرحلهبهمرحله، با وارسی پیش از جوابِ نهایی.** تصمیمِ صاحبِ
|
||||
پروژه (۲۰۲۶-۰۹-۲۴): دستیار راهحلِ کامل را توضیح میدهد، نه فقط راهنمایی.
|
||||
وارسی (جایگذاری، مشتقگیریِ دوباره) عمداً در پرامپت است: پاسخی از
|
||||
gpt-4o-mini مشتقِ x³ − 6x² + 9x را 3x² − 12x نوشته بود.
|
||||
3. **لحن سه سطح دارد** (دبستان / متوسطهی اول / متوسطهی دوم)، نه فقط نامِ پایه.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
@@ -13,6 +24,7 @@ from collections.abc import Iterator
|
||||
from django.conf import settings
|
||||
|
||||
from . import clients
|
||||
from .mathtext import StreamSanitizer, to_plain
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,68 +33,200 @@ _DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح
|
||||
# ⚠️ با هر تغییرِ معنادار در متنِ پرامپتهای پایین، این را یکی جلو ببرید.
|
||||
# روی هر پیامِ دستیار ذخیره میشود تا وقتی لایک/دیسلایکِ کاربرها جمع شد،
|
||||
# بشود گفت «کدام نسخهی پرامپت جوابِ بهتری میداد» — بدونِ این، همهی فیدبکها
|
||||
# در یک کاسه میریزند و مقایسهی نسخهها ممکن نیست.
|
||||
PROMPT_VERSION = 'tutor-v2-grade-aware'
|
||||
# در یک کاسه میریزند و مقایسهی نسخهها ممکن نیست. کشِ پاسخ هم با همین نسخه
|
||||
# کلید میخورد، پس جوابِ نسخهی قبلی بعد از دیپلوی دوباره سرو نمیشود.
|
||||
PROMPT_VERSION = 'tutor-v3-stepwise-plain'
|
||||
|
||||
# پایهها تا ششم دبستاناند، هفتم تا نهم متوسطهی اول و دهم به بعد متوسطهی دوم.
|
||||
_SECONDARY_GRADES = {'هفتم', 'هشتم', 'نهم', 'دهم', 'یازدهم', 'دوازدهم'}
|
||||
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
|
||||
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
|
||||
_UPPER = {'دهم', 'یازدهم', 'دوازدهم'}
|
||||
|
||||
# چند پیامِ آخرِ گفتگو که همراهِ سوالِ تازه فرستاده میشود. سه رفتوبرگشت
|
||||
# برای «نفهمیدم» و «یه مثال دیگه» کافی است و هزینهی توکن را محدود نگه میدارد.
|
||||
HISTORY_TURNS = 6
|
||||
_HISTORY_CHARS = 1500
|
||||
|
||||
|
||||
def grade_tier(grade_title: str) -> str:
|
||||
"""'primary' | 'middle' | 'upper' | '' (نامعلوم)."""
|
||||
grade = (grade_title or '').strip()
|
||||
if grade in _PRIMARY:
|
||||
return 'primary'
|
||||
if grade in _MIDDLE:
|
||||
return 'middle'
|
||||
if grade in _UPPER:
|
||||
return 'upper'
|
||||
return ''
|
||||
|
||||
|
||||
def _persona(grade_title: str) -> tuple[str, str]:
|
||||
"""(توصیفِ معلم، توصیفِ دانشآموز) متناسب با پایه."""
|
||||
grade = (grade_title or '').strip()
|
||||
if grade in _SECONDARY_GRADES:
|
||||
tier = grade_tier(grade)
|
||||
if tier in ('middle', 'upper'):
|
||||
return f'معلمِ ریاضیِ پایهی {grade}', f'دانشآموزِ پایهی {grade}'
|
||||
if grade:
|
||||
return f'معلمِ پایهی {grade} دبستان', f'دانشآموزِ پایهی {grade}'
|
||||
return 'معلمِ فارسیزبان', 'دانشآموز'
|
||||
if tier == 'primary':
|
||||
return f'معلمِ پایهی {grade} دبستان', f'دانشآموزِ پایهی {grade} دبستان'
|
||||
return 'معلمِ ریاضیِ فارسیزبان', 'دانشآموز'
|
||||
|
||||
|
||||
_TONE = {
|
||||
'primary': (
|
||||
'با یک بچهی دبستانی حرف میزنی: مهربان، صبور و تشویقکننده.\n'
|
||||
'- جملهها کوتاه و ساده باشند؛ هر مرحله فقط یک کار.\n'
|
||||
'- با مثالهای روزمره توضیح بده (سیب، شکلات، پول، خطکش، ساعت).\n'
|
||||
'- اگر اصطلاحِ کتاب را بهکار میبری، اول با زبانِ خودمانی معنیاش را بگو.\n'
|
||||
'- حداکثر یک ایموجیِ تشویقی در کلِ پاسخ.'
|
||||
),
|
||||
'middle': (
|
||||
'با یک نوجوانِ متوسطهی اول حرف میزنی: دوستانه و روشن، مثلِ معلمی که '
|
||||
'بچهها دوستش دارند.\n'
|
||||
'- اصطلاحهای کتاب را بهکار ببر و هرجا لازم است با یک جمله معنیاش را بگو.\n'
|
||||
'- قاعدهای را که در هر مرحله استفاده میکنی نام ببر (مثلاً «اتحادِ مربعِ دوجملهای»).'
|
||||
),
|
||||
'upper': (
|
||||
'با یک دانشآموزِ دبیرستان حرف میزنی: دقیق، فشرده و محترمانه.\n'
|
||||
'- نمادگذاری و اصطلاحهای همان کتاب را بهکار ببر.\n'
|
||||
'- در هر مرحله قاعده یا قضیهای را که استفاده میکنی نام ببر '
|
||||
'(مثلاً «مشتقِ حاصلضرب»، «قضیهی فیثاغورس»، «رفعِ ابهامِ 0/0»).\n'
|
||||
'- شرطها را صریح چک کن: دامنه، مخرجِ مخالفِ صفر، زیرِ رادیکالِ نامنفی، '
|
||||
'علامتِ عبارت، واحدِ زاویه.'
|
||||
),
|
||||
'': 'دوستانه، روشن و دقیق حرف بزن.',
|
||||
}
|
||||
|
||||
_FORMAT = (
|
||||
'قالبِ پاسخ (مهم — پاسخ در یک اپِ موبایل بهصورتِ متنِ ساده نمایش داده میشود):\n'
|
||||
'- فقط متنِ ساده. هیچ LaTeX ننویس (نه \\( \\)، نه \\[ \\]، نه \\frac، نه $) و '
|
||||
'هیچ markdown ننویس (نه #، نه **، نه جدول).\n'
|
||||
'- برای ریاضی از نمادهای یونیکد استفاده کن: x²، x³، √2، ∛8، ×، ÷، ±، ≤، ≥، ≠، '
|
||||
'≈، π، ∞، ∈، ∪، ∩، →، °، f′(x).\n'
|
||||
'{numbers}\n'
|
||||
'- هر مرحله در یک خطِ جدا و شمارهدار باشد: «۱) …»، «۲) …». هر فرمولِ مهم '
|
||||
'در خطِ خودش.\n'
|
||||
'- بیمقدمه شروع کن؛ سوال را تکرار نکن و حرفِ اضافه نزن.'
|
||||
)
|
||||
|
||||
# در کتابهای درسیِ ایران «/» بینِ دو رقم ممیزِ اعشار است (۲/۵ یعنی دو و نیم).
|
||||
# بچهی دبستانی همین را میشناسد؛ پس برای او کسر با کلمه نوشته میشود. از
|
||||
# هفتم به بعد فرمولها طولانیاند و (x+1)/(x−2) بیجایگزین است، پس ممیز نقطه
|
||||
# میشود تا «/» فقط یک معنا داشته باشد.
|
||||
_NUMBERS = {
|
||||
'primary': (
|
||||
'- مثلِ کتابِ درسیِ خودش بنویس: رقمهای فارسی، و ممیزِ اعشار با «/» (۲/۵ یعنی '
|
||||
'دو و نیم). کسر را با کلمه بنویس: «۳ بر ۴» یا «سهچهارم» — هرگز با «/».'
|
||||
),
|
||||
'default': (
|
||||
'- اعدادِ داخلِ فرمول را با رقمِ انگلیسی بنویس و ممیزِ اعشار را با نقطه '
|
||||
'(3.5). «/» فقط یعنی کسر: (x+1)/(x−2).'
|
||||
),
|
||||
}
|
||||
|
||||
_METHOD = (
|
||||
'روشِ پاسخ:\n'
|
||||
'- اگر سوال یک مسئله است، آن را کامل و مرحلهبهمرحله حل کن تا دانشآموز '
|
||||
'بتواند مسیر را دنبال کند؛ در هر مرحله بگو چه میکنیم و چرا. هیچ مرحلهی '
|
||||
'محاسبهای را جا نینداز.\n'
|
||||
'- اگر سوال مفهومی است، مفهوم را با یک مثالِ عددیِ کوتاه و حلشده توضیح بده.\n'
|
||||
'- دقت از همهچیز مهمتر است. پیش از نوشتنِ جوابِ نهایی آن را وارسی کن '
|
||||
'(جواب را در معادله جایگذاری کن، مشتق یا سادهسازی را دوباره انجام بده، '
|
||||
'بزرگیِ عدد را تخمین بزن). اگر در وارسی اشتباهی دیدی، راهحل را اصلاح کن؛ '
|
||||
'هرگز جوابی را که وارسی نکردهای ننویس.\n'
|
||||
'- اگر مسئله جوابِ مشخصی دارد، خطِ آخرِ حل این باشد: «✅ جواب: …».\n'
|
||||
'- اگر اطلاعاتِ مسئله ناقص است (مثلاً به شکلی اشاره میکند که نمیبینی)، '
|
||||
'بگو چه چیزی لازم است، بهجای حدس زدن.\n'
|
||||
'- اگر دانشآموز میگوید نفهمیده یا توضیحِ بیشتر میخواهد، از گفتگوی قبلی '
|
||||
'بفهم دربارهی چیست و همان را سادهتر، با راهی دیگر یا مثالی تازه توضیح بده.\n'
|
||||
'- در پایانِ حلِ یک مسئله، یک تمرینِ کوتاهِ مشابه بده: «حالا خودت امتحان کن: …» '
|
||||
'(بدونِ جوابش).\n'
|
||||
'- اگر سوال ربطی به درس و مدرسه ندارد، مؤدبانه و کوتاه بگو که کارت کمک در '
|
||||
'ریاضی است و بچه را به درس برگردان.'
|
||||
)
|
||||
|
||||
|
||||
def system_prompt(grade_title: str = '') -> str:
|
||||
"""
|
||||
قبلاً «معلمِ دبستانی» ثابت بود. برای پایههای نهم/دهم این هم لحن را بچگانه
|
||||
میکرد و هم مدل را وادار میکرد مفاهیمی مثل اتحاد، مثلثات و تابع را تا حدِ
|
||||
دبستان ساده کند — یعنی دقیقاً همانجایی که بچهها گیر میکنند بیفایده
|
||||
میشد. حالا لحن از پایه میآید.
|
||||
قبلاً «معلمِ دبستانی» ثابت بود؛ بعد فقط نامِ پایه در آن آمد. حالا لحن،
|
||||
روش (حلِ کامل + وارسی) و قالب (متنِ ساده) هر سه صریحاند.
|
||||
"""
|
||||
teacher, _ = _persona(grade_title)
|
||||
teacher, student = _persona(grade_title)
|
||||
numbers = _NUMBERS['primary' if grade_tier(grade_title) == 'primary' else 'default']
|
||||
return (
|
||||
f'تو یک {teacher} و فارسیزبان هستی. پاسخها را روشن، دقیق و '
|
||||
'گامبهگام میدهی و از نمادها و اصطلاحهای ریاضیِ همان پایه استفاده '
|
||||
'میکنی؛ نه سادهتر از سطحِ کتاب، نه پیچیدهتر از آن.'
|
||||
f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} '
|
||||
f'با یک {student} حرف میزنی. همیشه فارسی جواب بده.\n\n'
|
||||
f'{_TONE[grade_tier(grade_title)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}'
|
||||
)
|
||||
|
||||
|
||||
def _prompt_with_context(question: str, contexts: list[str], grade_title: str = '') -> str:
|
||||
joined = '\n\n---\n\n'.join(contexts)
|
||||
teacher, student = _persona(grade_title)
|
||||
return f"""
|
||||
تو {teacher} هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ
|
||||
{student} پاسخِ روشن و گامبهگام بده. لازم نیست پاسخ عیناً در متن باشد؛
|
||||
میتوانی از اطلاعاتِ متن نتیجهگیری و توضیح بدهی.
|
||||
اگر مسئله چند مرحله دارد، مرحلهها را جدا و شمارهگذاریشده بنویس.
|
||||
فقط اگر متنِ درس آشکارا دربارهی موضوعِ دیگریست و هیچ ربطی به سوال ندارد،
|
||||
بنویس: «این موضوع در این درس پوشش داده نشده است.»
|
||||
def _user_prompt(question: str, contexts: list[str], focus: str = '') -> str:
|
||||
asked = f'سوالِ دانشآموز: {question}'
|
||||
if focus:
|
||||
asked = f'{focus}\n\n{asked}'
|
||||
if not contexts:
|
||||
return asked if focus else question
|
||||
excerpts = '\n\n'.join(f'[{i}] {text}' for i, text in enumerate(contexts, 1))
|
||||
return (
|
||||
'چند بخش از کتابِ درسیِ همین دانشآموز که با جستجو پیدا شدهاند '
|
||||
'(ممکن است همه مربوط نباشند):\n\n'
|
||||
f'{excerpts}\n\n'
|
||||
'— پایانِ بخشهای کتاب —\n'
|
||||
'اگر به سوال مربوطاند، از روش و نمادگذاریِ همانها پیروی کن؛ اگر مربوط '
|
||||
'نیستند نادیدهشان بگیر و از دانشِ خودت در سطحِ همین پایه جواب بده. '
|
||||
'متنِ کتاب از روی تصویر خوانده شده و ممکن است غلطِ تایپی داشته باشد؛ '
|
||||
'حسابِ خودت را ملاک بگذار. در متنِ کتاب «/» بینِ دو رقم ممیزِ اعشار است '
|
||||
'(۳/۱۰۴ یعنی 3.104)، نه کسر.\n\n'
|
||||
f'{asked}'
|
||||
)
|
||||
|
||||
متنِ درس:
|
||||
{joined}
|
||||
|
||||
سوالِ {student}: {question}
|
||||
def chat_model_for(grade_title: str = '') -> str:
|
||||
"""
|
||||
|
||||
|
||||
def _prompt_without_context(question: str, grade_title: str = '') -> str:
|
||||
teacher, student = _persona(grade_title)
|
||||
return f"""
|
||||
تو {teacher} هستی. به این سوالِ یک {student} به فارسی، روشن و گامبهگام
|
||||
پاسخ بده.
|
||||
|
||||
سوال: {question}
|
||||
مدلِ چت برای این پایه. `OPENAI_CHAT_MODEL_UPPER` (اختیاری) فقط برای
|
||||
دهم تا دوازدهم است، جایی که حسابان/مثلثات مدلِ قویتری میخواهد و
|
||||
هزینهی اضافهاش ارزش دارد؛ خالی یعنی همهی پایهها یک مدل.
|
||||
"""
|
||||
upper = getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or ''
|
||||
if upper and grade_tier(grade_title) == 'upper':
|
||||
return upper
|
||||
return settings.OPENAI_CHAT_MODEL
|
||||
|
||||
|
||||
def is_reasoning_model(model: str) -> bool:
|
||||
"""مدلهای reasoning (o-series، gpt-5) temperature نمیپذیرند."""
|
||||
name = (model or '').lower().split('/')[-1]
|
||||
return name.startswith(('o1', 'o3', 'o4', 'gpt-5'))
|
||||
|
||||
|
||||
def completion_kwargs(model: str) -> dict:
|
||||
if is_reasoning_model(model):
|
||||
effort = getattr(settings, 'OPENAI_CHAT_REASONING_EFFORT', '') or ''
|
||||
return {'reasoning_effort': effort} if effort else {}
|
||||
return {'temperature': 0.2}
|
||||
|
||||
|
||||
def build_messages(question: str, context_chunks: list[dict] | None,
|
||||
grade_title: str = '', history: list[dict] | None = None,
|
||||
focus: str = '') -> list[dict]:
|
||||
"""
|
||||
history: [{'role': 'user'|'assistant', 'text': ...}, ...] به ترتیبِ زمان.
|
||||
|
||||
بدونِ history، «نفهمیدم» یا «یه مثال دیگه بزن» (که خودِ اپ بهعنوانِ
|
||||
دکمهی پیشنهادی نشان میدهد) برای مدل هیچ معنایی نداشت.
|
||||
"""
|
||||
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
|
||||
messages = [{'role': 'system', 'content': system_prompt(grade_title)}]
|
||||
for turn in (history or [])[-HISTORY_TURNS:]:
|
||||
role = 'assistant' if turn.get('role') == 'assistant' else 'user'
|
||||
text = (turn.get('text') or '').strip()
|
||||
if text:
|
||||
messages.append({'role': role, 'content': text[:_HISTORY_CHARS]})
|
||||
messages.append({'role': 'user', 'content': _user_prompt(question, contexts, focus)})
|
||||
return messages
|
||||
|
||||
|
||||
def generate_answer(question: str, context_chunks: list[dict] | None,
|
||||
grade_title: str = '') -> tuple[str, list[str], dict]:
|
||||
grade_title: str = '', history: list[dict] | None = None,
|
||||
model: str | None = None, focus: str = '') -> tuple[str, list[str], dict]:
|
||||
"""
|
||||
context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[].
|
||||
خروجی: (answer, follow_ups, meta)
|
||||
@@ -90,70 +234,111 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
|
||||
`meta` همان چیزیست که روی پیامِ دستیار ذخیره میشود: مدل، نسخهی پرامپت،
|
||||
توکنِ مصرفشده و مدتِ تولید. بدونِ اینها نه میشود فهمید کدام نسخهی پرامپت
|
||||
بهتر جواب میدهد، نه اینکه چقدر از سهمیهی API خرج شده است.
|
||||
`model` فقط برای مقایسهی مدلها (`rag_model_benchmark`) است.
|
||||
"""
|
||||
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
|
||||
prompt = (
|
||||
_prompt_with_context(question, contexts, grade_title) if contexts
|
||||
else _prompt_without_context(question, grade_title)
|
||||
)
|
||||
model = model or chat_model_for(grade_title)
|
||||
messages = build_messages(question, context_chunks, grade_title, history, focus)
|
||||
|
||||
client = clients.get_openai_client()
|
||||
started = time.monotonic()
|
||||
completion = client.chat.completions.create(
|
||||
model=settings.OPENAI_CHAT_MODEL,
|
||||
messages=[
|
||||
{'role': 'system', 'content': system_prompt(grade_title)},
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
temperature=0.3,
|
||||
model=model, messages=messages, **completion_kwargs(model),
|
||||
)
|
||||
latency_ms = int((time.monotonic() - started) * 1000)
|
||||
|
||||
answer = (completion.choices[0].message.content or '').strip()
|
||||
raw = (completion.choices[0].message.content or '').strip()
|
||||
answer = to_plain(raw, fraction_word=grade_tier(grade_title) == 'primary')
|
||||
if not answer:
|
||||
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
|
||||
|
||||
usage = getattr(completion, 'usage', None)
|
||||
meta = {
|
||||
'model': settings.OPENAI_CHAT_MODEL,
|
||||
'model': model,
|
||||
'promptVersion': PROMPT_VERSION,
|
||||
'latencyMs': latency_ms,
|
||||
'promptTokens': getattr(usage, 'prompt_tokens', None) or 0,
|
||||
'completionTokens': getattr(usage, 'completion_tokens', None) or 0,
|
||||
'hadContext': bool(contexts),
|
||||
'hadContext': any(c.get('summary') for c in (context_chunks or [])),
|
||||
# آیا تورِ ایمنی چیزی را عوض کرد؟ نشانِ این است که مدل قاعدهی «متنِ
|
||||
# ساده» را رعایت نکرده — برای مقایسهی مدلها در rag_model_benchmark.
|
||||
'sanitized': answer != raw,
|
||||
}
|
||||
return answer, list(_DEFAULT_FOLLOW_UPS), meta
|
||||
|
||||
|
||||
def stream_answer(question: str, context_chunks: list[dict] | None,
|
||||
grade_title: str = '') -> Iterator[str]:
|
||||
grade_title: str = '', history: list[dict] | None = None,
|
||||
focus: str = '') -> Iterator[str]:
|
||||
"""
|
||||
همون generate_answer ولی توکنبهتوکن (برای SSE). فقط قطعههای متنِ پاسخ
|
||||
را yield میکند؛ follow_upها ثابتاند (_DEFAULT_FOLLOW_UPS) و caller خودش
|
||||
اضافهشان میکند.
|
||||
"""
|
||||
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
|
||||
prompt = (
|
||||
_prompt_with_context(question, contexts, grade_title) if contexts
|
||||
else _prompt_without_context(question, grade_title)
|
||||
)
|
||||
همون generate_answer ولی تکهبهتکه (برای SSE). فقط متنِ پاسخ را yield
|
||||
میکند؛ follow_upها ثابتاند و caller خودش اضافهشان میکند.
|
||||
|
||||
متن خطبهخط از StreamSanitizer رد میشود، چون یک دستورِ LaTeX ممکن است
|
||||
بینِ دو تکهی استریم نصف شود.
|
||||
"""
|
||||
model = chat_model_for(grade_title)
|
||||
client = clients.get_openai_client()
|
||||
stream = client.chat.completions.create(
|
||||
model=settings.OPENAI_CHAT_MODEL,
|
||||
messages=[
|
||||
{'role': 'system', 'content': system_prompt(grade_title)},
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
temperature=0.3,
|
||||
model=model,
|
||||
messages=build_messages(question, context_chunks, grade_title, history, focus),
|
||||
stream=True,
|
||||
**completion_kwargs(model),
|
||||
)
|
||||
sanitizer = StreamSanitizer(fraction_word=grade_tier(grade_title) == 'primary')
|
||||
for event in stream:
|
||||
if not event.choices:
|
||||
continue
|
||||
delta = event.choices[0].delta.content or ''
|
||||
if delta:
|
||||
yield delta
|
||||
clean = sanitizer.feed(delta)
|
||||
if clean:
|
||||
yield clean
|
||||
tail = sanitizer.flush()
|
||||
if tail:
|
||||
yield tail
|
||||
|
||||
|
||||
def question_focus(question_id, user_id) -> str:
|
||||
"""
|
||||
متنِ سوالی از اپ که دانشآموز دربارهاش میپرسد، با گزینهها، آخرین جوابِ
|
||||
خودش و جوابِ درست — تا دستیار بگوید اشتباه دقیقاً کجاست.
|
||||
|
||||
سینیور خواسته بود ثابت شود دستیار «همانجایی که بچهها گیر میکنند» کمک
|
||||
میکند؛ بدونِ این، دستیار سوالی را که بچه همین الان غلط زده نمیدید.
|
||||
خالی برمیگرداند اگر سوال پیدا نشود (هرگز خطا نمیدهد).
|
||||
"""
|
||||
try:
|
||||
from content.models import Question, UserAnswer
|
||||
|
||||
question = Question.objects.prefetch_related('choices').filter(pk=int(question_id)).first()
|
||||
except (TypeError, ValueError):
|
||||
return ''
|
||||
except Exception: # noqa: BLE001 — اپ content در دسترس نیست
|
||||
logger.exception('question_focus: خواندنِ سوال %s شکست خورد', question_id)
|
||||
return ''
|
||||
if question is None:
|
||||
return ''
|
||||
choices = list(question.choices.all())
|
||||
correct = next((c.value for c in choices if c.is_correct), '')
|
||||
last = (
|
||||
UserAnswer.objects.filter(user_id=user_id, question=question)
|
||||
.select_related('choice').order_by('-id').first()
|
||||
)
|
||||
lines = [
|
||||
'دانشآموز دربارهی این سوالِ تمرینِ اپ میپرسد:',
|
||||
f'صورتِ سوال: {question.prompt}',
|
||||
'گزینهها: ' + ' | '.join(c.value for c in choices),
|
||||
f'جوابِ درست: {correct}',
|
||||
]
|
||||
if last is not None:
|
||||
verdict = 'درست' if last.correct else 'غلط'
|
||||
lines.append(f'آخرین جوابِ دانشآموز: {last.choice.value} ({verdict})')
|
||||
if not last.correct:
|
||||
lines.append(
|
||||
'اول بگو اشتباهِ احتمالیِ او در کدام مرحله بوده (بدونِ سرزنش)، بعد '
|
||||
'راهحلِ درست را مرحلهبهمرحله توضیح بده.'
|
||||
)
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def default_follow_ups() -> list[str]:
|
||||
|
||||
308
rag/services/mathtext.py
Normal file
308
rag/services/mathtext.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
تبدیلِ LaTeX و markdown در پاسخِ دستیار به متنِ ساده با نمادهای یونیکد.
|
||||
|
||||
چرا لازم است: صفحهی «از پی بپرس» پاسخ را همانطور که هست، بهصورتِ متنِ خام
|
||||
نشان میدهد. مدلها برای ریاضی عادت دارند `\\( \\frac{1}{2} \\)` و `### مرحله ۱`
|
||||
بنویسند؛ روی گوشی اینها عیناً دیده میشوند و فرمولِ حسابان به رشتهای از
|
||||
بکاسلش و آکولاد تبدیل میشود. پرامپت از مدل میخواهد متنِ ساده بنویسد، ولی
|
||||
مدل همیشه گوش نمیدهد — این ماژول تورِ ایمنیِ قطعی پشتِ آن است.
|
||||
|
||||
قاعدهی مهم: آکولادی که جزوِ LaTeX نیست دست نمیخورد. مجموعهها در ریاضیِ نهم
|
||||
با `{1, 2, 3}` نوشته میشوند و نباید پاک شوند.
|
||||
"""
|
||||
import re
|
||||
from contextvars import ContextVar
|
||||
|
||||
# برای دبستان «/» ممیزِ اعشار است (مثلِ کتابشان)، پس کسر «۳ بر ۴» نوشته میشود.
|
||||
_FRACTION_WORD: ContextVar[bool] = ContextVar('fraction_word', default=False)
|
||||
|
||||
_SUPERSCRIPT = str.maketrans('0123456789+-=()nixyk', '⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱˣʸᵏ')
|
||||
_SUBSCRIPT = str.maketrans('0123456789+-=()', '₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎')
|
||||
_SUP_CHARS = set('0123456789+-=()nixyk')
|
||||
_SUB_CHARS = set('0123456789+-=()')
|
||||
|
||||
_SYMBOLS = {
|
||||
'times': '×', 'cdot': '·', 'div': '÷', 'pm': '±', 'mp': '∓',
|
||||
'leq': '≤', 'le': '≤', 'geq': '≥', 'ge': '≥', 'neq': '≠', 'ne': '≠',
|
||||
'approx': '≈', 'simeq': '≃', 'equiv': '≡', 'infty': '∞',
|
||||
'pi': 'π', 'alpha': 'α', 'beta': 'β', 'gamma': 'γ', 'theta': 'θ',
|
||||
'Delta': 'Δ', 'delta': 'δ', 'lambda': 'λ', 'mu': 'μ', 'sigma': 'σ',
|
||||
'Sigma': 'Σ', 'phi': 'φ', 'varphi': 'φ', 'omega': 'ω', 'epsilon': 'ε',
|
||||
'varepsilon': 'ε', 'rho': 'ρ', 'tau': 'τ',
|
||||
'rightarrow': '→', 'to': '→', 'leftarrow': '←', 'Rightarrow': '⇒',
|
||||
'implies': '⇒', 'Leftrightarrow': '⇔', 'iff': '⇔', 'mapsto': '↦',
|
||||
'in': '∈', 'notin': '∉', 'subset': '⊂', 'subseteq': '⊆', 'supset': '⊃',
|
||||
'cup': '∪', 'cap': '∩', 'emptyset': '∅', 'varnothing': '∅',
|
||||
'forall': '∀', 'exists': '∃', 'angle': '∠', 'perp': '⊥', 'parallel': '∥',
|
||||
'triangle': '△', 'circ': '∘', 'degree': '°', 'prime': '′',
|
||||
'ldots': '…', 'dots': '…', 'cdots': '⋯', 'int': '∫', 'sum': 'Σ',
|
||||
'prod': '∏', 'therefore': '∴', 'propto': '∝', 'neg': '¬',
|
||||
'quad': ' ', 'qquad': ' ', 'lbrace': '{', 'rbrace': '}',
|
||||
'setminus': '−', 'mid': '|', 'vert': '|',
|
||||
}
|
||||
_PREFIX_SIGNS = {'pm', 'mp', 'neg', 'angle', 'triangle', 'forall', 'exists'}
|
||||
_BLACKBOARD = {'R': 'ℝ', 'N': 'ℕ', 'Z': 'ℤ', 'Q': 'ℚ', 'C': 'ℂ'}
|
||||
# \sin و \log و ... فقط بکاسلششان حذف میشود.
|
||||
_FUNCTIONS = {
|
||||
'sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'log', 'ln', 'exp', 'lim',
|
||||
'max', 'min', 'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'det', 'gcd',
|
||||
}
|
||||
# دستورهایی که فقط محتوای آرگومانشان میماند.
|
||||
_UNWRAP = {'text', 'textbf', 'textit', 'mathrm', 'mathbf', 'mathit', 'operatorname',
|
||||
'boxed', 'displaystyle', 'mbox', 'underline', 'bm', 'textrm'}
|
||||
_DROP = {'left', 'right', 'big', 'Big', 'bigg', 'Bigg', 'displaystyle', 'limits',
|
||||
'nonumber', 'hline', 'noindent'}
|
||||
|
||||
|
||||
def _read_group(s: str, i: int) -> tuple[str, int]:
|
||||
"""از s[i] یک آرگومانِ LaTeX میخواند: {…} با آکولادِ تودرتو، یا یک نویسه."""
|
||||
while i < len(s) and s[i] == ' ':
|
||||
i += 1
|
||||
if i >= len(s):
|
||||
return '', i
|
||||
if s[i] != '{':
|
||||
if s[i] == '\\':
|
||||
m = re.match(r'\\[A-Za-z]+', s[i:])
|
||||
if m:
|
||||
return m.group(0), i + len(m.group(0))
|
||||
return s[i], i + 1
|
||||
depth, j = 0, i
|
||||
while j < len(s):
|
||||
if s[j] == '\\' and j + 1 < len(s):
|
||||
j += 2
|
||||
continue
|
||||
if s[j] == '{':
|
||||
depth += 1
|
||||
elif s[j] == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return s[i + 1:j], j + 1
|
||||
j += 1
|
||||
return s[i + 1:], len(s)
|
||||
|
||||
|
||||
_ATOM = re.compile(r'√?[\d.]+|√?\w|\(.*\)')
|
||||
|
||||
|
||||
def _needs_parens(expr: str) -> bool:
|
||||
"""«2h» در مخرج باید (2h) شود، وگرنه 1/2h یعنی h/2."""
|
||||
return not _ATOM.fullmatch(expr.strip())
|
||||
|
||||
|
||||
def _script(body: str, table, allowed: set, marker: str) -> str:
|
||||
if body and all(ch in allowed for ch in body):
|
||||
return body.translate(table)
|
||||
return f'{marker}({body})' if len(body) > 1 else f'{marker}{body}'
|
||||
|
||||
|
||||
def _convert(s: str) -> str:
|
||||
"""یک عبارتِ LaTeX را (بازگشتی) به یونیکد تبدیل میکند."""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
ch = s[i]
|
||||
if ch == '\\':
|
||||
if i + 1 < len(s) and not s[i + 1].isalpha():
|
||||
nxt = s[i + 1]
|
||||
if nxt == '\\':
|
||||
out.append('\n')
|
||||
elif nxt in '{}%$&#_':
|
||||
out.append(nxt)
|
||||
elif nxt in ',;:! ':
|
||||
out.append(' ' if nxt != '!' else '')
|
||||
else:
|
||||
out.append(nxt)
|
||||
i += 2
|
||||
continue
|
||||
m = re.match(r'\\([A-Za-z]+)', s[i:])
|
||||
name = m.group(1)
|
||||
i += len(m.group(0))
|
||||
if name == 'frac' or name in ('dfrac', 'tfrac'):
|
||||
num, i = _read_group(s, i)
|
||||
den, i = _read_group(s, i)
|
||||
num, den = _convert(num), _convert(den)
|
||||
num = f'({num})' if _needs_parens(num) else num
|
||||
den = f'({den})' if _needs_parens(den) else den
|
||||
out.append(f'{num} بر {den}' if _FRACTION_WORD.get() else f'{num}/{den}')
|
||||
elif name == 'sqrt':
|
||||
index = ''
|
||||
if i < len(s) and s[i] == '[':
|
||||
close = s.find(']', i)
|
||||
if close != -1:
|
||||
index, i = s[i + 1:close], close + 1
|
||||
body, i = _read_group(s, i)
|
||||
body = _convert(body)
|
||||
root = (index.translate(_SUPERSCRIPT) if index else '') + '√'
|
||||
out.append(root + (f'({body})' if len(body) > 1 else body))
|
||||
elif name == 'mathbb':
|
||||
body, i = _read_group(s, i)
|
||||
out.append(_BLACKBOARD.get(body, body))
|
||||
elif name in ('vec', 'overrightarrow'):
|
||||
body, i = _read_group(s, i)
|
||||
out.append(_convert(body) + '⃗')
|
||||
elif name in ('overline', 'bar', 'widehat', 'hat'):
|
||||
body, i = _read_group(s, i)
|
||||
out.append(_convert(body))
|
||||
elif name in ('begin', 'end'):
|
||||
_env, i = _read_group(s, i)
|
||||
elif name in _UNWRAP:
|
||||
if i < len(s) and s[i] == '{':
|
||||
body, i = _read_group(s, i)
|
||||
out.append(_convert(body))
|
||||
elif name in _DROP:
|
||||
# \left( → ( — خودِ پرانتز در دورِ بعدِ حلقه نوشته میشود.
|
||||
if name in ('left', 'right') and i < len(s) and s[i] == '.':
|
||||
i += 1
|
||||
elif name in _SYMBOLS:
|
||||
out.append(_SYMBOLS[name])
|
||||
# «\pm 2» → «±2»: نمادِ یکطرفه به عددش میچسبد.
|
||||
if name in _PREFIX_SIGNS:
|
||||
while i < len(s) and s[i] == ' ':
|
||||
i += 1
|
||||
elif name in _FUNCTIONS:
|
||||
out.append(name)
|
||||
else:
|
||||
out.append(name)
|
||||
elif ch in '^_':
|
||||
# مدل «x^10» را به معنیِ ده مینویسد، نه «x¹0» — رقمها با هم خوانده میشوند.
|
||||
digits = re.match(r'\d+', s[i + 1:])
|
||||
if digits:
|
||||
body, i = digits.group(0), i + 1 + len(digits.group(0))
|
||||
else:
|
||||
body, i = _read_group(s, i + 1)
|
||||
if ch == '^' and body in ('\\circ', 'circ', '°'):
|
||||
out.append('°')
|
||||
continue
|
||||
if ch == '^' and body in ('\\prime', "'"):
|
||||
out.append('′')
|
||||
continue
|
||||
body = _convert(body)
|
||||
if ch == '_' and ''.join(out).endswith('lim'):
|
||||
out.append(f'({body})')
|
||||
continue
|
||||
if ch == '^':
|
||||
out.append(_script(body, _SUPERSCRIPT, _SUP_CHARS, '^'))
|
||||
else:
|
||||
out.append(_script(body, _SUBSCRIPT, _SUB_CHARS, '_'))
|
||||
elif ch == '{':
|
||||
# آکولادِ گروهبندیِ LaTeX (بعد از پاک شدنِ دستورِ صاحبش)
|
||||
body, i = _read_group(s, i)
|
||||
out.append(_convert(body))
|
||||
elif ch == '&':
|
||||
i += 1
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
# \( … \) و \[ … \] و $$ … $$ و $ … $ — محتوا تبدیل و جداکننده حذف میشود.
|
||||
_MATH_SPANS = re.compile(
|
||||
r'\\\[(.+?)\\\]|\\\((.+?)\\\)|\$\$(.+?)\$\$|(?<![\w$])\$([^$\n]+?)\$(?!\w)',
|
||||
re.DOTALL,
|
||||
)
|
||||
# LaTeXِ بیجداکننده که مدل گاهی وسطِ متن مینویسد: \frac{..}{..}، x^{2}، \sqrt{..}
|
||||
_BARE_LATEX = re.compile(r'\\[A-Za-z{}]|[\^_]\{|(?<=[\w)])\^\d')
|
||||
|
||||
|
||||
def _math_span(match: re.Match) -> str:
|
||||
body = next(g for g in match.groups() if g is not None)
|
||||
# بلوکِ نمایشی را مدل خودش در خطِ جدا مینویسد؛ خطِ خالیِ اضافه نمیسازیم.
|
||||
if '\\\\' in body: # چندخطی (\\ یا cases/aligned)
|
||||
return '\n'.join(' '.join(line.split()) for line in _convert(body).strip().split('\n'))
|
||||
return ' '.join(_convert(body).split())
|
||||
|
||||
|
||||
def _strip_markdown(text: str) -> str:
|
||||
lines = []
|
||||
for line in text.split('\n'):
|
||||
stripped = line.strip()
|
||||
if re.fullmatch(r'\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?', stripped):
|
||||
continue # خطِ جداکنندهی جدولِ markdown
|
||||
if stripped.startswith('|') and stripped.endswith('|') and stripped.count('|') >= 2:
|
||||
cells = [c.strip() for c in stripped.strip('|').split('|')]
|
||||
line = ' | '.join(cells)
|
||||
line = re.sub(r'^\s{0,3}#{1,6}\s+', '', line)
|
||||
line = re.sub(r'^(\s*)[-*]\s+', r'\1• ', line)
|
||||
line = re.sub(r'^\s*(-{3,}|\*{3,}|_{3,})\s*$', '', line)
|
||||
line = re.sub(r'\*\*(.+?)\*\*', r'\1', line)
|
||||
line = re.sub(r'__(.+?)__', r'\1', line)
|
||||
line = line.replace('`', '')
|
||||
lines.append(line.strip()) # تورفتگی در حبابِ راستبهچپ معنایی ندارد
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def to_plain(text: str, *, fraction_word: bool = False) -> str:
|
||||
"""
|
||||
پاسخِ مدل → متنِ سادهی قابلِ خواندن روی گوشی.
|
||||
fraction_word: کسرِ LaTeX بهصورتِ «a بر b» (برای دبستان) بهجای a/b.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
token = _FRACTION_WORD.set(fraction_word)
|
||||
try:
|
||||
return _to_plain(text)
|
||||
finally:
|
||||
_FRACTION_WORD.reset(token)
|
||||
|
||||
|
||||
def _to_plain(text: str) -> str:
|
||||
text = _MATH_SPANS.sub(_math_span, text)
|
||||
if _BARE_LATEX.search(text):
|
||||
text = '\n'.join(_convert(line) if _BARE_LATEX.search(line) else line
|
||||
for line in text.split('\n'))
|
||||
text = _strip_markdown(text)
|
||||
text = re.sub(r'[ \t]+\n', '\n', text)
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
class StreamSanitizer:
|
||||
"""
|
||||
همان to_plain برای پاسخِ استریمی. یک دستورِ LaTeX ممکن است بینِ دو تکهی
|
||||
استریم نصف شود (`\\fr` + `ac{1}{2}`)، پس متن تا پایانِ هر خط نگه داشته و
|
||||
خطبهخط تبدیل میشود؛ یک بلوکِ `\\[ … \\]` چندخطی هم تا بسته شدنش نگه
|
||||
داشته میشود.
|
||||
"""
|
||||
|
||||
def __init__(self, *, fraction_word: bool = False):
|
||||
self._fraction_word = fraction_word
|
||||
self._buffer = ''
|
||||
self._emitted_any = False
|
||||
self._newlines = 0 # خطهای خالیِ معلق؛ مثلِ to_plain حداکثر دوتا
|
||||
|
||||
def _ready_cut(self) -> int:
|
||||
cut = self._buffer.rfind('\n')
|
||||
if cut == -1:
|
||||
return -1
|
||||
head = self._buffer[:cut + 1]
|
||||
# بلوکِ نمایشیِ باز را نصفه نفرست.
|
||||
if head.count('\\[') > head.count('\\]') or head.count('$$') % 2:
|
||||
return -1
|
||||
return cut + 1
|
||||
|
||||
def feed(self, delta: str) -> str:
|
||||
self._buffer += delta
|
||||
cut = self._ready_cut()
|
||||
if cut == -1:
|
||||
return ''
|
||||
head, self._buffer = self._buffer[:cut], self._buffer[cut:]
|
||||
return self._emit(head, final=False)
|
||||
|
||||
def flush(self) -> str:
|
||||
head, self._buffer = self._buffer, ''
|
||||
return self._emit(head, final=True)
|
||||
|
||||
def _emit(self, chunk: str, *, final: bool) -> str:
|
||||
plain = to_plain(chunk, fraction_word=self._fraction_word)
|
||||
if not plain:
|
||||
# خطِ واقعاً خالی خطِ خالی است؛ خطی که محتوایش حذف شد (جداکنندهی
|
||||
# جدول) مثلِ to_plain هیچ ردی نمیگذارد.
|
||||
if not chunk.strip():
|
||||
self._newlines += chunk.count('\n')
|
||||
return ''
|
||||
leading = len(chunk) - len(chunk.lstrip('\n'))
|
||||
prefix = '\n' * min(self._newlines + leading, 2) if self._emitted_any else ''
|
||||
self._emitted_any = True
|
||||
self._newlines = 0 if final else len(chunk) - len(chunk.rstrip('\n'))
|
||||
return prefix + plain
|
||||
@@ -107,15 +107,23 @@ def _payload_text(payload: dict) -> str:
|
||||
دو شکلِ payload در دادهٔ تیمِ دیگه هست:
|
||||
- درسهای متنی (علوم/اجتماعی): کلیدِ `summary`
|
||||
- ریاضی: کلیدِ `generalSolution` (+ problem/solution) و بدونِ `summary`
|
||||
|
||||
⚠️ راهحلی که `rag_verify_solutions` با آن مخالفت کرده (`disagrees`) به مدل
|
||||
نمیرسد؛ فقط روش و صورتِ مسئله میرود. پیشتر همان راهحلِ غلط (مثلاً «در
|
||||
[−π, π] دو نوسانِ کامل») بهعنوانِ «متنِ درس» به دستیار داده میشد و دستیار
|
||||
تکرارش میکرد — حدودِ ۱۳٪ تکههای کتابهای دهم تا دوازدهم اینطورند.
|
||||
"""
|
||||
if payload.get('summary'):
|
||||
return payload['summary']
|
||||
solution = payload.get('solution', '')
|
||||
if payload.get('solution_verdict') == 'disagrees':
|
||||
solution = ''
|
||||
parts = [
|
||||
payload.get('generalSolution', ''),
|
||||
payload.get('problem', ''),
|
||||
payload.get('solution', ''),
|
||||
('روش', payload.get('generalSolution', '')),
|
||||
('مسئله', payload.get('problem', '')),
|
||||
('راهحلِ کتاب', solution),
|
||||
]
|
||||
joined = '\n'.join(p for p in parts if p).strip()
|
||||
joined = '\n'.join(f'{label}: {text}' for label, text in parts if text).strip()
|
||||
return joined or payload.get('text', '')
|
||||
|
||||
|
||||
|
||||
302
rag/tests_tutor_quality.py
Normal file
302
rag/tests_tutor_quality.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
کیفیتِ پاسخِ دستیار (۲۰۲۶-۰۹-۲۵): متنِ ساده بهجای LaTeX، لحنِ سهسطحی، حلِ
|
||||
مرحلهبهمرحله با وارسی، حافظهی گفتگو، و کنار گذاشتنِ راهحلهای غلطِ کتاب.
|
||||
|
||||
هر تست یکی از ایرادهایی را میپوشاند که روی جوابهای واقعی دیده شد؛
|
||||
توضیحِ هرکدام کنارِ خودِ کد است.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
|
||||
from .services.clients import reset_clients_cache
|
||||
from .services.mathtext import StreamSanitizer, to_plain
|
||||
|
||||
RAG_ON = dict(
|
||||
OPENAI_API_KEY='test-openai', QDRANT_API_KEY='test-qdrant', RAG_FORCE_MOCK=False,
|
||||
)
|
||||
|
||||
|
||||
class PlainMathTextTests(SimpleTestCase):
|
||||
def test_inline_and_display_latex_become_unicode(self):
|
||||
text = r'مشتق \(f(x)=x^{3}-6x^2+9x\) برابر است با:' '\n' r'\[ f\'(x) = 3x^2 - 12x + 9 \]'
|
||||
self.assertEqual(to_plain(text), "مشتق f(x)=x³-6x²+9x برابر است با:\nf'(x) = 3x² - 12x + 9")
|
||||
|
||||
def test_fractions_keep_their_meaning(self):
|
||||
# «1/2h» یعنی h/2 — مخرجِ چندنویسهای باید پرانتز بگیرد.
|
||||
self.assertEqual(to_plain(r'\(\frac{a}{2h}\)'), 'a/(2h)')
|
||||
self.assertEqual(to_plain(r'\(\frac{x+1}{x-2}\)'), '(x+1)/(x-2)')
|
||||
self.assertEqual(to_plain(r'\(\frac{\sqrt{3}}{2}\)'), '√3/2')
|
||||
|
||||
def test_primary_fractions_are_written_in_words(self):
|
||||
# در کتابِ دبستان «۲/۵» یعنی دو و نیم؛ کسر نباید با «/» نوشته شود.
|
||||
self.assertEqual(to_plain(r'\(\frac{3}{4}\)', fraction_word=True), '3 بر 4')
|
||||
|
||||
def test_roots_limits_degrees_and_symbols(self):
|
||||
self.assertEqual(to_plain(r'\(\sqrt[3]{8} = 2\)'), '³√8 = 2')
|
||||
self.assertEqual(to_plain(r'\(\lim_{x \to 0} \frac{\sin x}{x} = 1\)'), 'lim(x → 0) (sin x)/x = 1')
|
||||
self.assertEqual(to_plain(r'\(30^\circ\)'), '30°')
|
||||
self.assertEqual(to_plain(r'\(a \leq b \neq c \times d\)'), 'a ≤ b ≠ c × d')
|
||||
self.assertEqual(to_plain('x^10 + y^2'), 'x¹⁰ + y²')
|
||||
|
||||
def test_set_braces_survive(self):
|
||||
# مجموعهها در ریاضیِ نهم با آکولاد نوشته میشوند.
|
||||
self.assertEqual(to_plain('A = {1, 2, 3}'), 'A = {1, 2, 3}')
|
||||
self.assertEqual(to_plain(r'\(A = \{1, 2\} \cup B\)'), 'A = {1, 2} ∪ B')
|
||||
|
||||
def test_markdown_is_removed(self):
|
||||
text = '### مرحله ۱\n**نکته:** این مهم است\n| x | y |\n|---|---|\n| 1 | 2 |\n- اول'
|
||||
self.assertEqual(to_plain(text), 'مرحله ۱\nنکته: این مهم است\nx | y\n1 | 2\n• اول')
|
||||
|
||||
def test_plain_text_passes_through(self):
|
||||
text = '۱) دو طرف را بر ۲ تقسیم میکنیم.\n۲) x = 3\n✅ جواب: 3'
|
||||
self.assertEqual(to_plain(text), text)
|
||||
|
||||
def test_stream_output_equals_batch_output_however_it_is_split(self):
|
||||
text = (
|
||||
'### حل\nاول \\(x^{2}\\) را حساب میکنیم:\n\\[\n\\frac{a+b}{2}\n\\]\n\n\n'
|
||||
'| a | b |\n|---|---|\n| 1 | 2 |\n**تمام** \\(\\sqrt{16}=4\\)'
|
||||
)
|
||||
for step in (1, 2, 3, 7, 50):
|
||||
sanitizer = StreamSanitizer()
|
||||
out = ''.join(sanitizer.feed(text[i:i + step]) for i in range(0, len(text), step))
|
||||
out += sanitizer.flush()
|
||||
self.assertEqual(out, to_plain(text), f'step={step}')
|
||||
|
||||
|
||||
class TutorPromptTests(SimpleTestCase):
|
||||
def test_three_tiers_have_different_voices(self):
|
||||
from rag.services.generation import system_prompt
|
||||
|
||||
primary, middle, upper = system_prompt('سوم'), system_prompt('هشتم'), system_prompt('یازدهم')
|
||||
self.assertIn('دبستانی', primary)
|
||||
self.assertIn('سیب', primary)
|
||||
self.assertNotIn('دبستان', middle)
|
||||
self.assertIn('دبیرستان', upper)
|
||||
self.assertIn('دامنه', upper)
|
||||
|
||||
def test_every_prompt_demands_full_steps_a_check_and_plain_text(self):
|
||||
from rag.services.generation import system_prompt
|
||||
|
||||
for grade in ('سوم', 'نهم', 'دوازدهم', ''):
|
||||
prompt = system_prompt(grade)
|
||||
self.assertIn('مرحلهبهمرحله', prompt)
|
||||
self.assertIn('وارسی', prompt)
|
||||
self.assertIn('✅ جواب', prompt)
|
||||
self.assertIn('LaTeX', prompt)
|
||||
|
||||
def test_decimal_notation_follows_the_book_for_primary(self):
|
||||
from rag.services.generation import system_prompt
|
||||
|
||||
self.assertIn('۲/۵ یعنی', system_prompt('ششم'))
|
||||
self.assertIn('(3.5)', system_prompt('دهم'))
|
||||
|
||||
def test_history_goes_before_the_new_question(self):
|
||||
from rag.services.generation import HISTORY_TURNS, build_messages
|
||||
|
||||
history = [{'role': 'user', 'text': f'سوال {i}'} if i % 2 == 0
|
||||
else {'role': 'assistant', 'text': f'جواب {i}'} for i in range(10)]
|
||||
messages = build_messages('نفهمیدم', [{'summary': 'متنِ کتاب'}], 'نهم', history)
|
||||
self.assertEqual(messages[0]['role'], 'system')
|
||||
self.assertEqual(len(messages), 1 + HISTORY_TURNS + 1)
|
||||
self.assertEqual(messages[-2], {'role': 'assistant', 'content': 'جواب 9'})
|
||||
self.assertTrue(messages[-1]['content'].endswith('سوالِ دانشآموز: نفهمیدم'))
|
||||
self.assertIn('متنِ کتاب', messages[-1]['content'])
|
||||
|
||||
def test_reasoning_models_get_no_temperature(self):
|
||||
from rag.services.generation import completion_kwargs
|
||||
|
||||
self.assertEqual(completion_kwargs('gpt-4o-mini'), {'temperature': 0.2})
|
||||
with override_settings(OPENAI_CHAT_REASONING_EFFORT='low'):
|
||||
self.assertEqual(completion_kwargs('o4-mini'), {'reasoning_effort': 'low'})
|
||||
self.assertEqual(completion_kwargs('gpt-5-mini'), {'reasoning_effort': 'low'})
|
||||
|
||||
@override_settings(OPENAI_CHAT_MODEL='cheap', OPENAI_CHAT_MODEL_UPPER='strong')
|
||||
def test_upper_grades_can_have_their_own_model(self):
|
||||
from rag.services.generation import chat_model_for
|
||||
|
||||
self.assertEqual(chat_model_for('دوازدهم'), 'strong')
|
||||
self.assertEqual(chat_model_for('نهم'), 'cheap')
|
||||
self.assertEqual(chat_model_for(''), 'cheap')
|
||||
|
||||
|
||||
@override_settings(**RAG_ON)
|
||||
class GenerateAnswerSanitizesTests(TestCase):
|
||||
def setUp(self):
|
||||
reset_clients_cache()
|
||||
|
||||
def tearDown(self):
|
||||
reset_clients_cache()
|
||||
|
||||
def _client(self, reply):
|
||||
completions = SimpleNamespace(create=lambda **kw: SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content=reply))], usage=None))
|
||||
return SimpleNamespace(chat=SimpleNamespace(completions=completions))
|
||||
|
||||
def test_latex_from_the_model_never_reaches_the_student(self):
|
||||
from rag.services.generation import generate_answer
|
||||
|
||||
with patch('rag.services.clients.get_openai_client',
|
||||
return_value=self._client(r'۱) \(x^2 = 4\)' '\n' r'✅ جواب: \(x = \pm 2\)')):
|
||||
answer, _f, meta = generate_answer('x² = 4', None, 'نهم')
|
||||
self.assertEqual(answer, '۱) x² = 4\n✅ جواب: x = ±2')
|
||||
self.assertTrue(meta['sanitized'])
|
||||
self.assertEqual(meta['promptVersion'], 'tutor-v3-stepwise-plain')
|
||||
|
||||
|
||||
class DisputedSolutionTests(SimpleTestCase):
|
||||
def _payload(self, verdict):
|
||||
return {'generalSolution': 'روش', 'problem': 'مسئله', 'solution': 'راهحلِ کتاب',
|
||||
'solution_verdict': verdict}
|
||||
|
||||
def test_a_solution_the_checker_disputes_is_not_shown_to_the_tutor(self):
|
||||
from rag.services.retrieval import _payload_text
|
||||
|
||||
text = _payload_text(self._payload('disagrees'))
|
||||
self.assertIn('مسئله', text)
|
||||
self.assertNotIn('راهحلِ کتاب:', text)
|
||||
|
||||
def test_agreed_and_unchecked_solutions_still_go(self):
|
||||
from rag.services.retrieval import _payload_text
|
||||
|
||||
for verdict in ('agrees', 'unjudgeable', None):
|
||||
self.assertIn('راهحلِ کتاب: راهحلِ کتاب', _payload_text(self._payload(verdict)))
|
||||
|
||||
|
||||
@override_settings(**RAG_ON, RAG_ANSWER_CACHE_TTL=300)
|
||||
class ConversationMemoryTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
reset_clients_cache()
|
||||
self.client = APIClient()
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def _user(self, phone):
|
||||
user = User.objects.create(phone=phone, is_guest=False)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
return client
|
||||
|
||||
def _ask(self, client, text, conversation_id=None):
|
||||
body = {'userQuestion': text, 'subject': 'ریاضی', 'grade': 'نهم', 'lessonNumber': 4}
|
||||
if conversation_id:
|
||||
body['conversationId'] = conversation_id
|
||||
return client.post('/api/ai/ask', body, format='json')
|
||||
|
||||
def test_follow_up_sees_the_conversation_and_searches_with_the_topic(self):
|
||||
client = self._user('09120000901')
|
||||
retrieve = patch('rag.services.retrieval.retrieve_context',
|
||||
return_value=([], {'gradeTitle': 'نهم'}))
|
||||
generate = patch('rag.services.generation.generate_answer',
|
||||
return_value=('جوابِ توان', [], {}))
|
||||
with retrieve as retrieve_mock, generate as generate_mock:
|
||||
first = self._ask(client, '۲ به توان منفی ۳ چند است؟')
|
||||
self._ask(client, 'نفهمیدم', first.json()['conversationId'])
|
||||
|
||||
history = generate_mock.call_args.kwargs['history']
|
||||
self.assertEqual([t['text'] for t in history], ['۲ به توان منفی ۳ چند است؟', 'جوابِ توان'])
|
||||
self.assertIn('توان منفی', retrieve_mock.call_args.args[0])
|
||||
|
||||
def test_a_follow_up_is_never_served_from_someone_elses_cache(self):
|
||||
replies = iter(['جوابِ اولِ الف', 'جوابِ «نفهمیدم»ِ الف', 'جوابِ اولِ ب', 'جوابِ «نفهمیدم»ِ ب'])
|
||||
retrieve = patch('rag.services.retrieval.retrieve_context',
|
||||
return_value=([], {'gradeTitle': 'نهم'}))
|
||||
generate = patch('rag.services.generation.generate_answer',
|
||||
side_effect=lambda *a, **k: (next(replies), [], {}))
|
||||
with retrieve, generate:
|
||||
a, b = self._user('09120000902'), self._user('09120000903')
|
||||
conv_a = self._ask(a, 'اتحاد چیست؟').json()['conversationId']
|
||||
self._ask(a, 'نفهمیدم', conv_a)
|
||||
conv_b = self._ask(b, 'تجزیه چیست؟').json()['conversationId']
|
||||
second_b = self._ask(b, 'نفهمیدم', conv_b)
|
||||
|
||||
self.assertEqual(second_b.json()['answer'], 'جوابِ «نفهمیدم»ِ ب')
|
||||
|
||||
|
||||
@override_settings(**RAG_ON)
|
||||
class ProviderStateTests(TestCase):
|
||||
"""اعتبارِ تمامشده باید در /api/health دیده شود، نه فقط در لاگ."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
reset_clients_cache()
|
||||
user = User.objects.create(phone='09120000904', is_guest=False)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def _ask(self, generate):
|
||||
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
|
||||
patch('rag.services.generation.generate_answer', **generate):
|
||||
return self.client.post('/api/ai/ask', {'userQuestion': 'سلام'}, format='json')
|
||||
|
||||
def test_exhausted_credit_shows_up_in_health_and_clears_on_recovery(self):
|
||||
quota = RuntimeError("Error code: 403 - insufficient user quota, remaining quota: $-0.001")
|
||||
self.assertEqual(self._ask({'side_effect': quota}).status_code, 503)
|
||||
|
||||
health = self.client.get('/api/health').json()
|
||||
self.assertEqual(health['integrations']['aiProvider']['state'], 'quota_exhausted')
|
||||
self.assertTrue(any('GapGPT' in w for w in health['warnings']))
|
||||
|
||||
self.assertEqual(self._ask({'return_value': ('جواب', [], {})}).status_code, 200)
|
||||
health = self.client.get('/api/health').json()
|
||||
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
|
||||
|
||||
def test_an_ordinary_network_error_does_not_claim_the_credit_is_gone(self):
|
||||
self.assertEqual(self._ask({'side_effect': ConnectionError('timeout')}).status_code, 503)
|
||||
health = self.client.get('/api/health').json()
|
||||
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
|
||||
|
||||
|
||||
@override_settings(**RAG_ON)
|
||||
class QuestionFocusTests(TestCase):
|
||||
"""«چرا جوابم غلط بود؟» — دستیار باید همان سوال و جوابِ دانشآموز را ببیند."""
|
||||
|
||||
def setUp(self):
|
||||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||||
|
||||
cache.clear()
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='ف', order=1),
|
||||
title='ن', order=1)
|
||||
self.question = Question.objects.create(lesson=Lesson.objects.create(node=node, title='د'),
|
||||
type='multiple_choice', prompt='۲ به توان منفی ۳؟')
|
||||
self.right = Choice.objects.create(question=self.question, value='1/8', is_correct=True)
|
||||
self.wrong = Choice.objects.create(question=self.question, value='-8', is_correct=False)
|
||||
user = User.objects.create(phone='09120000905', is_guest=False, grade_id=grade.id)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def test_the_tutor_sees_the_question_and_the_wrong_answer(self):
|
||||
self.client.post(f'/api/questions/{self.question.id}/answer',
|
||||
{'selectedOptionId': self.wrong.id}, format='json')
|
||||
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
|
||||
patch('rag.services.generation.generate_answer',
|
||||
return_value=('جواب', [], {})) as generate:
|
||||
response = self.client.post('/api/ai/ask', {
|
||||
'userQuestion': 'چرا غلط شد؟', 'questionId': str(self.question.id),
|
||||
}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
focus = generate.call_args.kwargs['focus']
|
||||
self.assertIn('۲ به توان منفی ۳؟', focus)
|
||||
self.assertIn('-8 (غلط)', focus)
|
||||
self.assertIn('جوابِ درست: 1/8', focus)
|
||||
|
||||
def test_an_overlong_question_is_refused_before_any_paid_call(self):
|
||||
with patch('rag.services.generation.generate_answer') as generate:
|
||||
response = self.client.post('/api/ai/ask', {'userQuestion': 'x' * 2001}, format='json')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
generate.assert_not_called()
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user