senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental settings); progress.0006 drops its tables, permissions and migration history, and Profile.parent_phone, the two family notification types and ?userId= on /api/progress/nodes go with it - GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes, questions, accuracy, islands, tutor questions) for the "parent report" page - profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it - GET /api/payment/invoices; orders now snapshot list price and plan title - tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11), refusal marker replaced with fixed text; self-harm gets a help line instead - streamed answers now record token usage (it was always 0) - daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota - delete_users management command for wiping test accounts across every app - docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -111,9 +111,11 @@ class AskAIGenerationFailureTests(TestCase):
|
||||
|
||||
def test_generation_failure_does_not_consume_quota_slot(self):
|
||||
# یک سوالِ ناموفق میفرستیم (که نباید کوتا مصرف کنه)، بعد دقیقاً
|
||||
# DAILY_AI_QUOTA سوالِ موفق پشتِ سرِ هم میفرستیم؛ همه باید ۲۰۰
|
||||
# به اندازهی سقفِ روزانه سوالِ موفق پشتِ سرِ هم میفرستیم؛ همه باید ۲۰۰
|
||||
# بگیرن، یعنی سوالِ ناموفقِ اول اسلاتی از کوتا مصرف نکرده.
|
||||
from .views import DAILY_AI_QUOTA
|
||||
from .views import daily_quota
|
||||
|
||||
DAILY_AI_QUOTA = daily_quota(self.user.id)
|
||||
|
||||
with patch(
|
||||
'ai_tutor.views.generate_tutor_answer',
|
||||
@@ -126,7 +128,7 @@ class AskAIGenerationFailureTests(TestCase):
|
||||
ok_response = self._ask()
|
||||
self.assertEqual(ok_response.status_code, 200)
|
||||
|
||||
# این سوالِ اضافه باید رد بشه چون سقفِ ۱۵ (نه ۱۴) واقعاً پر شده.
|
||||
# این سوالِ اضافه باید رد بشه چون سقف (نه یکی کمتر) واقعاً پر شده.
|
||||
throttled_response = self._ask()
|
||||
self.assertEqual(throttled_response.status_code, 429)
|
||||
|
||||
|
||||
53
ai_tutor/tests_quota.py
Normal file
53
ai_tutor/tests_quota.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
سقفِ روزانهی دستیار: رایگان در برابرِ اشتراکی (۲۰۲۶-۱۰-۰۱). تا آن روز همه ۱۵ سوال
|
||||
داشتند، یعنی گرانترین بخشِ اپ برای کاربرِ بیاشتراک هم کامل باز بود.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from subscription.models import Plan, Subscription
|
||||
from subscription.services import ai_daily_quota
|
||||
|
||||
|
||||
@override_settings(AI_DAILY_QUOTA_FREE=2)
|
||||
class DailyQuotaTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120006001', is_guest=False)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
|
||||
def ask(self):
|
||||
return self.client.post('/api/ai/ask', {'userQuestion': '۲ + ۲ چند میشه؟'}, format='json')
|
||||
|
||||
def test_free_user_gets_the_free_quota_and_a_hint_to_subscribe(self):
|
||||
self.assertEqual([self.ask().status_code for _ in range(2)], [200, 200])
|
||||
blocked = self.ask()
|
||||
self.assertEqual(blocked.status_code, 429)
|
||||
self.assertIn('اشتراک', blocked.json()['detail'])
|
||||
|
||||
def test_remaining_quota_follows_the_users_own_limit(self):
|
||||
self.assertEqual(self.ask().json()['remainingQuota'], 1)
|
||||
|
||||
def test_subscriber_gets_the_plans_quota(self):
|
||||
plan = Plan.objects.create(title='ماهانه', price=99000, duration_days=30, ai_daily_quota=4)
|
||||
Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
|
||||
expires_at=timezone.now() + timedelta(days=30))
|
||||
self.assertEqual(ai_daily_quota(self.user.id), 4)
|
||||
self.assertEqual([self.ask().status_code for _ in range(5)], [200, 200, 200, 200, 429])
|
||||
|
||||
def test_expired_subscription_falls_back_to_free(self):
|
||||
plan = Plan.objects.create(title='ماهانه', price=99000, duration_days=30, ai_daily_quota=40)
|
||||
Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
|
||||
expires_at=timezone.now() - timedelta(days=1))
|
||||
self.assertEqual(ai_daily_quota(self.user.id), 2)
|
||||
|
||||
def test_gifted_subscription_without_plan_gets_the_default(self):
|
||||
Subscription.objects.create(user_id=self.user.id, plan=None, status='active', expires_at=None)
|
||||
self.assertEqual(ai_daily_quota(self.user.id), 15)
|
||||
@@ -28,7 +28,11 @@ from .serializers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAILY_AI_QUOTA = 15
|
||||
def daily_quota(user_id: int) -> int:
|
||||
"""سقفِ سوالِ روزانهی این کاربر؛ رایگان یا از پلنِ اشتراکش (subscription.services)."""
|
||||
from subscription.services import ai_daily_quota
|
||||
|
||||
return ai_daily_quota(user_id)
|
||||
|
||||
|
||||
def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool) -> dict:
|
||||
@@ -77,7 +81,7 @@ class GuestAwareAIThrottle(ScopedRateThrottle):
|
||||
مشترکِ throttle bucket ai_ask رو دارن و دیگه با عوضکردنِ حساب
|
||||
نمیشه bucket تازه گرفت. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده با شماره) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی
|
||||
میمونه؛ منطقِ DAILY_AI_QUOTA و بقیهی view دستنخوردهست.
|
||||
میمونه؛ منطقِ سقفِ روزانه (daily_quota) و بقیهی view دستنخوردهست.
|
||||
"""
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
@@ -216,7 +220,7 @@ class AskAIView(APIView):
|
||||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# DAILY_AI_QUOTA فقط سقفِ روزانهست و جلوی burstِ کوتاهمدت رو نمیگیره؛
|
||||
# daily_quota فقط سقفِ روزانهست و جلوی burstِ کوتاهمدت رو نمیگیره؛
|
||||
# این throttle_scope مجزا اون burst رو محدود میکنه.
|
||||
throttle_scope = 'ai_ask'
|
||||
throttle_classes = [GuestAwareAIThrottle]
|
||||
@@ -263,8 +267,15 @@ class AskAIView(APIView):
|
||||
used_today = Message.objects.filter(
|
||||
conversation__user_id=user.id, role='user', created_at__date=today
|
||||
).count()
|
||||
if used_today >= DAILY_AI_QUOTA:
|
||||
raise Throttled(detail='سقف روزانهی سوال از هوش مصنوعی به پایان رسیده است.')
|
||||
quota = daily_quota(user.id)
|
||||
if used_today >= quota:
|
||||
from subscription.services import has_unlimited_hearts as is_subscriber
|
||||
|
||||
raise Throttled(detail=(
|
||||
f'سقفِ امروزِ سوال از دستیار ({quota} سوال) پر شد؛ فردا دوباره میتوانی بپرسی.'
|
||||
if is_subscriber(user.id) else
|
||||
f'سوالهای رایگانِ امروز ({quota} سوال) تمام شد. با خریدِ اشتراک هر روز سوالِ بیشتری میتوانی بپرسی.'
|
||||
))
|
||||
|
||||
conversation_id = v.get('conversationId')
|
||||
conversation_is_new = not conversation_id
|
||||
@@ -351,6 +362,13 @@ class AskAIView(APIView):
|
||||
conversation.grade_id = conversation.grade_id or grade_id
|
||||
conversation.save(update_fields=['subject', 'grade_id'])
|
||||
|
||||
# محافظِ دستیار، لایهی ۱ (rag/services/guardrails.py): ترفندِ آشکار برای
|
||||
# عوض کردنِ قواعد، یا جملهای دربارهی آسیب به خود، اصلاً به مدل نمیرسد.
|
||||
# سوال در سهمیهی روز شمرده میشود تا تکرارش ارزان نباشد.
|
||||
from rag.services import guardrails
|
||||
|
||||
screened = guardrails.screen(v['userQuestion'])
|
||||
|
||||
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندیشده.
|
||||
# قراردادِ JSONِ غیراستریم دستنخورده میماند؛ Swagger که stream
|
||||
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
||||
@@ -363,7 +381,7 @@ class AskAIView(APIView):
|
||||
user_message=user_message, conversation_is_new=conversation_is_new,
|
||||
idempotency_key=idempotency_key, used_today=used_today,
|
||||
subject=subject, grade_id=grade_id, grade_title=grade_title,
|
||||
lesson_number=lesson_number,
|
||||
lesson_number=lesson_number, screened=screened, quota=quota,
|
||||
)
|
||||
|
||||
used_chunks: list[dict] = []
|
||||
@@ -374,7 +392,10 @@ class AskAIView(APIView):
|
||||
try:
|
||||
from rag.services.config import rag_is_configured
|
||||
|
||||
if rag_is_configured():
|
||||
if screened:
|
||||
answer_text, follow_ups = screened[1], []
|
||||
generation_meta = {'promptVersion': f'guardrail-{screened[0]}'}
|
||||
elif rag_is_configured():
|
||||
from rag.services import answer_cache
|
||||
from rag.services.generation import generate_answer
|
||||
from rag.services.retrieval import resolve_target, retrieve_context
|
||||
@@ -408,7 +429,10 @@ class AskAIView(APIView):
|
||||
history=history, focus=focus,
|
||||
)
|
||||
provider_status.record_success()
|
||||
if cacheable:
|
||||
# ردِ «غیرِ ریاضی» نه منبعِ کتاب نشان میدهد نه کش میشود.
|
||||
if generation_meta.get('refused'):
|
||||
used_chunks = []
|
||||
elif cacheable:
|
||||
answer_cache.set(cache_key, {
|
||||
'answer': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
@@ -455,7 +479,7 @@ class AskAIView(APIView):
|
||||
return Response(self._build_ask_response(existing).data)
|
||||
|
||||
used_today += 1 # سوال همین درخواست هم جزو مصرف امروز است
|
||||
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
|
||||
remaining_quota = max(quota - used_today, 0)
|
||||
|
||||
data = {
|
||||
'conversationId': str(conversation.id),
|
||||
@@ -484,7 +508,7 @@ class AskAIView(APIView):
|
||||
|
||||
def _streaming_response(self, *, request, v, user, conversation, user_message,
|
||||
conversation_is_new, idempotency_key, used_today,
|
||||
subject, grade_id, grade_title, lesson_number):
|
||||
subject, grade_id, grade_title, lesson_number, screened=None, quota=None):
|
||||
"""
|
||||
نسخهی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی
|
||||
پاسخ توکنبهتوکن استریم میشه. رویدادها:
|
||||
@@ -512,8 +536,13 @@ class AskAIView(APIView):
|
||||
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:
|
||||
cached = answer_cache.get(cache_key) if cacheable and not screened else None
|
||||
if screened:
|
||||
answer_text, follow_ups = screened[1], []
|
||||
used_chunks, retrieval_meta = [], {}
|
||||
generation_meta = {'promptVersion': f'guardrail-{screened[0]}'}
|
||||
yield _sse('chunk', {'delta': answer_text})
|
||||
elif cached is not None:
|
||||
answer_text = cached['answer']
|
||||
follow_ups = list(cached['followUps'])
|
||||
used_chunks = cached['usedChunks']
|
||||
@@ -544,17 +573,22 @@ class AskAIView(APIView):
|
||||
provider_status.record_success()
|
||||
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||||
follow_ups = default_follow_ups()
|
||||
# مسیرِ استریم usage برنمیگرداند (پاسخ توکنبهتوکن است)،
|
||||
# پس فقط چیزی را ثبت میکنیم که واقعاً میدانیم؛ توکنها صفر
|
||||
# میمانند و گزارشِ هزینه هم همین را نشان میدهد.
|
||||
# توکنها از آخرین تکهی استریم میآیند (stream_options.include_usage).
|
||||
# تا ۲۰۲۶-۱۰-۰۱ اینجا صفر ثبت میشد، یعنی هزینهی واقعیِ اپ هیچجا
|
||||
# اندازهگیری نمیشد؛ providerی که usage نفرستد هنوز صفر میدهد.
|
||||
usage = stream_result.get('usage') or {}
|
||||
generation_meta = {
|
||||
'model': chat_model_for(effective_grade),
|
||||
'promptVersion': PROMPT_VERSION,
|
||||
'latencyMs': int((time.monotonic() - started) * 1000),
|
||||
'hadContext': bool(used_chunks),
|
||||
'diagram': stream_result.get('diagram'),
|
||||
'promptTokens': usage.get('promptTokens', 0),
|
||||
'completionTokens': usage.get('completionTokens', 0),
|
||||
}
|
||||
if cacheable:
|
||||
if stream_result.get('refused'):
|
||||
used_chunks = []
|
||||
elif cacheable:
|
||||
answer_cache.set(cache_key, {
|
||||
'answer': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
@@ -595,7 +629,7 @@ class AskAIView(APIView):
|
||||
'conversationId': str(existing.conversation_id),
|
||||
'messageId': str(existing.assistant_message_id),
|
||||
'suggestedFollowUps': existing.suggested_follow_ups,
|
||||
'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0),
|
||||
'remainingQuota': max(quota - used_today, 0),
|
||||
'diagram': existing.assistant_message.diagram,
|
||||
'usedChunks': [],
|
||||
})
|
||||
@@ -606,7 +640,7 @@ class AskAIView(APIView):
|
||||
'conversationId': str(conversation.id),
|
||||
'messageId': str(assistant_message.id),
|
||||
'suggestedFollowUps': follow_ups,
|
||||
'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0),
|
||||
'remainingQuota': max(quota - used_today, 0),
|
||||
'diagram': assistant_message.diagram,
|
||||
'usedChunks': [
|
||||
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||||
@@ -662,7 +696,7 @@ class AskAIView(APIView):
|
||||
used_today = Message.objects.filter(
|
||||
conversation__user_id=existing.user_id, role='user', created_at__date=today
|
||||
).count()
|
||||
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
|
||||
remaining_quota = max(daily_quota(existing.user_id) - used_today, 0)
|
||||
|
||||
data = {
|
||||
'conversationId': str(conversation.id),
|
||||
|
||||
Reference in New Issue
Block a user