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:
25
ai_tutor/migrations/0007_message_charge.py
Normal file
25
ai_tutor/migrations/0007_message_charge.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-02 07:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def mark_existing_as_free(apps, schema_editor):
|
||||
# پیامهای پیش از بستهها همه از سهمیهی روزانه بودند؛ بدونِ این، سوالهای امروزِ پیش از
|
||||
# دیپلو در شمارشِ سهمیه نمیآمدند.
|
||||
apps.get_model('ai_tutor', 'Message').objects.filter(role='user').update(charge='free')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai_tutor', '0006_message_diagram'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='charge',
|
||||
field=models.CharField(blank=True, choices=[('free', 'رایگانِ روزانه'), ('credit', 'اعتبارِ بسته'), ('none', 'بی\u200cهزینه')], default='', max_length=6),
|
||||
),
|
||||
migrations.RunPython(mark_existing_as_free, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -25,6 +25,11 @@ class Message(models.Model):
|
||||
role = models.CharField(max_length=10, choices=ROLE_CHOICES)
|
||||
text = models.TextField()
|
||||
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
|
||||
# فقط برای پیامِ کاربر: این سوال از کجا پرداخت شد. «free» از سهمیهی روزانه، «credit» از
|
||||
# اعتبارِ بسته (subscription.QuestionCredit)، «none» هیچ (رد در صافیِ سرور یا جوابِ کششده که
|
||||
# به API نرسید). سهمیهی رایگانِ امروز = تعدادِ «free»های امروز.
|
||||
CHARGE_CHOICES = [('free', 'رایگانِ روزانه'), ('credit', 'اعتبارِ بسته'), ('none', 'بیهزینه')]
|
||||
charge = models.CharField(max_length=6, choices=CHARGE_CHOICES, blank=True, default='')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# --- حاشیهنویسیِ تولید (فقط برای پیامهای role='assistant') --------------
|
||||
|
||||
@@ -34,7 +34,8 @@ class AskResponseSerializer(serializers.Serializer):
|
||||
messageId = serializers.CharField()
|
||||
answer = serializers.CharField()
|
||||
suggestedFollowUps = serializers.ListField(child=serializers.CharField())
|
||||
remainingQuota = serializers.IntegerField()
|
||||
remainingQuota = serializers.IntegerField(help_text='سوالهای رایگانِ باقیِ امروز.')
|
||||
credits = serializers.IntegerField(required=False, help_text='اعتبارِ بستههای سوال (منقضی نمیشود).')
|
||||
diagram = serializers.DictField(required=False, allow_null=True)
|
||||
# تکههای درسیِ بازیابیشده از Qdrant که پاسخ بر پایهشون ساخته شده؛
|
||||
# خالی یعنی retrieval انجام نشده (نبودِ subject/lessonNumber یا خاموشبودنِ RAG).
|
||||
@@ -66,3 +67,9 @@ class FeedbackRequestSerializer(serializers.Serializer):
|
||||
|
||||
class FeedbackResponseSerializer(serializers.Serializer):
|
||||
success = serializers.BooleanField()
|
||||
|
||||
|
||||
class QuotaResponseSerializer(serializers.Serializer):
|
||||
freeDaily = serializers.IntegerField(help_text='سوالهای رایگانِ هر روز برای این کاربر.')
|
||||
remainingQuota = serializers.IntegerField(help_text='سوالهای رایگانِ باقیِ امروز.')
|
||||
credits = serializers.IntegerField(help_text='اعتبارِ بستههای سوال؛ منقضی نمیشود.')
|
||||
|
||||
@@ -30,7 +30,7 @@ class DailyQuotaTests(TestCase):
|
||||
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'])
|
||||
self.assertIn('بسته', blocked.json()['detail'])
|
||||
|
||||
def test_remaining_quota_follows_the_users_own_limit(self):
|
||||
self.assertEqual(self.ask().json()['remainingQuota'], 1)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView
|
||||
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView, QuotaView
|
||||
|
||||
urlpatterns = [
|
||||
path('ai/ask', AskAIView.as_view(), name='ai-ask'),
|
||||
path('ai/quota', QuotaView.as_view(), name='ai-quota'),
|
||||
path('ai/conversations/<int:conversation_id>/messages', ConversationMessagesView.as_view(), name='ai-conversation-messages'),
|
||||
path('ai/messages/<int:message_id>/feedback', MessageFeedbackView.as_view(), name='ai-message-feedback'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user