question packs instead of monthly/yearly plans; hearts are real

- packs (20 / 60 / 150 questions for 45,000 / 119,000 / 259,000 toman) never expire and
  give 7 / 30 / 60 days of unlimited hearts; old subscription plans stay for existing
  subscribers but are no longer on sale
- credit ledger (subscription.QuestionCredit): +N per paid pack (once per order), -1 per
  tutor question that reaches the API; the free daily questions are spent first; questions
  stopped by the server filter, cached answers and failed answers cost nothing
- unlimited hearts from packs live in HeartsPass, so buying a pack never rewrites a
  running subscription
- Message.charge records how each question was paid; GET /api/ai/quota and the ask
  responses report free-left-today and credits
- HEARTS_ENFORCED now defaults to on: zero hearts stops answering until refill or a pack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 11:41:39 +03:30
parent 2edc56fc08
commit 58c8e6d014
18 changed files with 572 additions and 55 deletions

View File

@@ -19,6 +19,7 @@ from accounts.models import User as AccountUser
from . import provider_status
from .models import AskIdempotency, Conversation, Message, MessageFeedback
from .serializers import (
QuotaResponseSerializer,
AskRequestSerializer,
AskResponseSerializer,
ConversationMessagesResponseSerializer,
@@ -44,6 +45,30 @@ def _cache_grade(grade_title: str, age: int | None) -> str:
return f'{grade_title}|adult' if tone_for(grade_title, age) == 'adult' else grade_title
def quota_state(user_id: int) -> dict:
"""
{'remainingQuota', 'credits'}: سوال‌های رایگانِ باقیِ امروز و اعتبارِ بسته، هر بار تازه از
دیتابیس. همه‌ی پاسخ‌ها از همین می‌خوانند تا شمارنده‌ی اپ هیچ‌وقت از موجودیِ واقعی جدا نیفتد.
"""
from subscription.services import credit_balance
used_free = Message.objects.filter(
conversation__user_id=user_id, role='user', created_at__date=timezone.localdate(), charge='free',
).count()
return {'remainingQuota': max(daily_quota(user_id) - used_free, 0), 'credits': credit_balance(user_id)}
def _uncharge(user_message) -> None:
"""سوالی که به API نرسید (جوابِ کش‌شده): نه از سهمیه کم شود نه از اعتبار."""
from subscription.services import refund_credit
if user_message.charge == 'credit':
refund_credit(user_message.id)
if user_message.charge in ('free', 'credit'):
user_message.charge = 'none'
user_message.save(update_fields=['charge'])
def daily_quota(user_id: int) -> int:
"""سقفِ سوالِ روزانه‌ی این کاربر؛ رایگان یا از پلنِ اشتراکش (subscription.services)."""
from subscription.services import ai_daily_quota
@@ -276,22 +301,38 @@ class AskAIView(APIView):
# فاز ۲ (فراخوانی AI) کاملاً بیرون از هر قفل/تراکنشی اجرا می‌شود تا
# تأخیر شبکه قفل کاربر را نگه ندارد، فاز ۳ پیام دستیار و idempotency
# را ثبت می‌کند. اگر فاز ۲/۳ شکست بخورد، رزرو فاز ۱ آزاد می‌شود.
# محافظِ دستیار، لایه‌ی ۱ (rag/services/guardrails.py): ترفندِ آشکار برای عوض کردنِ
# قواعد، یا جمله‌ای درباره‌ی آسیب به خود، اصلاً به مدل نمی‌رسد؛ پس هزینه‌ای ندارد و از
# سهمیه و اعتبار هم کم نمی‌شود. سقفِ burst (throttle_scope=ai_ask) جلوی تکرارش را می‌گیرد.
from rag.services import guardrails
from subscription.services import credit_balance, spend_credit
screened = guardrails.screen(v['userQuestion'])
with transaction.atomic():
AccountUser.objects.select_for_update().get(pk=user.id)
# هر سوالی که به API می‌رسد پول است: اول از سهمیه‌ی رایگانِ امروز، بعد از اعتبارِ بسته
# (۲۰۲۶-۱۰-۰۲). قفلِ ردیفِ کاربر و IMMEDIATEِ SQLite جلوی خرجِ دوباره‌ی یک اعتبار را می‌گیرند.
today = timezone.localdate()
used_today = Message.objects.filter(
conversation__user_id=user.id, role='user', created_at__date=today
conversation__user_id=user.id, role='user', created_at__date=today, charge='free',
).count()
quota = daily_quota(user.id)
if used_today >= quota:
from subscription.services import has_unlimited_hearts as is_subscriber
if screened:
charge = 'none'
elif used_today < quota:
charge = 'free'
elif credit_balance(user.id) > 0:
charge = 'credit'
else:
from subscription.services import has_active_subscription
n = str(quota).translate(str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹'))
raise Throttled(detail=(
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد؛ فردا دوباره می‌توانی بپرسی.'
if is_subscriber(user.id) else
f'سوال‌های رایگانِ امروز ({n} سوال) تمام شد. با خریدِ اشتراک هر روز سوالِ بیشتری می‌توانی بپرسی.'
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد. با خریدِ بسته‌ی سوال همین الان ادامه بده.'
if has_active_subscription(user.id) else
f'سوال‌های رایگانِ امروز ({n} سوال) تمام شد. با خریدِ بسته‌ی سوال همین الان ادامه بده.'
))
conversation_id = v.get('conversationId')
@@ -323,7 +364,10 @@ class AskAIView(APIView):
role='user',
text=v['userQuestion'],
question_id=raw_question_id if _is_db_ref_id(raw_question_id) else None,
charge=charge,
)
if charge == 'credit':
spend_credit(user.id, user_message.id)
# قفل ردیف کاربر اینجا آزاد شده؛ فراخوانی AI بیرون از هر تراکنشی اجرا می‌شود.
# لایه‌ی RAG (اپ rag) اینجا context را می‌سازد: پرسش → embedding →
# جستجوی Qdrant (محدود به subject+grade+lessonNumber) → چند خلاصه.
@@ -379,12 +423,6 @@ 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'])
age = _learner_age(user)
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندی‌شده.
@@ -431,6 +469,7 @@ class AskAIView(APIView):
retrieval_meta = cached.get('retrievalMeta') or {}
generation_meta = cached.get('generationMeta') or {}
answer_from_cache = True
_uncharge(user_message)
else:
used_chunks, retrieval_meta = retrieve_context(
_retrieval_query(v['userQuestion'], history, focus),
@@ -496,15 +535,15 @@ class AskAIView(APIView):
self._release_reserved_slot(conversation, user_message, conversation_is_new)
return Response(self._build_ask_response(existing).data)
used_today += 1 # سوال همین درخواست هم جزو مصرف امروز است
remaining_quota = max(quota - used_today, 0)
state = quota_state(user.id)
data = {
'conversationId': str(conversation.id),
'messageId': str(assistant_message.id),
'answer': answer_text,
'suggestedFollowUps': follow_ups,
'remainingQuota': remaining_quota,
'remainingQuota': state['remainingQuota'],
'credits': state['credits'],
'diagram': assistant_message.diagram,
'usedChunks': [
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
@@ -568,6 +607,7 @@ class AskAIView(APIView):
retrieval_meta = cached.get('retrievalMeta') or {}
generation_meta = cached.get('generationMeta') or {}
answer_from_cache = True
_uncharge(user_message)
yield _sse('chunk', {'delta': answer_text})
else:
used_chunks, retrieval_meta = retrieve_context(
@@ -648,18 +688,17 @@ class AskAIView(APIView):
'conversationId': str(existing.conversation_id),
'messageId': str(existing.assistant_message_id),
'suggestedFollowUps': existing.suggested_follow_ups,
'remainingQuota': max(quota - used_today, 0),
**quota_state(user.id),
'diagram': existing.assistant_message.diagram,
'usedChunks': [],
})
return
used_today += 1
done = {
'conversationId': str(conversation.id),
'messageId': str(assistant_message.id),
'suggestedFollowUps': follow_ups,
'remainingQuota': max(quota - used_today, 0),
**quota_state(user.id),
'diagram': assistant_message.diagram,
'usedChunks': [
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
@@ -684,7 +723,10 @@ class AskAIView(APIView):
def _release_reserved_slot(self, conversation, user_message, conversation_is_new):
"""پاک‌کردن رزرو فاز ۱ وقتی فاز بعدی شکست می‌خورد، تا نه پیام یتیم بماند و نه کوتا مصرف‌شده حساب شود."""
from subscription.services import refund_credit
with transaction.atomic():
refund_credit(user_message.id)
if conversation_is_new:
conversation.delete()
else:
@@ -711,18 +753,15 @@ class AskAIView(APIView):
idempotent به‌خودی‌خود چیزی مصرف نکرده.
"""
conversation = existing.conversation
today = timezone.localdate()
used_today = Message.objects.filter(
conversation__user_id=existing.user_id, role='user', created_at__date=today
).count()
remaining_quota = max(daily_quota(existing.user_id) - used_today, 0)
state = quota_state(existing.user_id)
data = {
'conversationId': str(conversation.id),
'messageId': str(existing.assistant_message.id),
'answer': existing.assistant_message.text,
'suggestedFollowUps': existing.suggested_follow_ups,
'remainingQuota': remaining_quota,
'remainingQuota': state['remainingQuota'],
'credits': state['credits'],
'diagram': existing.assistant_message.diagram,
}
return AskResponseSerializer(data)
@@ -774,3 +813,19 @@ class MessageFeedbackView(APIView):
defaults['reason'] = serializer.validated_data['reason']
MessageFeedback.objects.update_or_create(message=message, defaults=defaults)
return Response(FeedbackResponseSerializer({'success': True}).data)
class QuotaView(APIView):
"""
GET /api/ai/quota — «امروز X سوالِ رایگان + Y سوالِ خریده‌شده». اپ پیش از اولین سوال این را
نشان می‌دهد؛ بعد از هر جواب همین عددها در خودِ پاسخ هم می‌آیند (remainingQuota، credits).
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
@extend_schema(responses=QuotaResponseSerializer, tags=['AI Tutor (ai_tutor)'])
def get(self, request):
data = {'freeDaily': daily_quota(request.user.id), **quota_state(request.user.id)}
return Response(QuotaResponseSerializer(data).data)