148 lines
6.4 KiB
Python
148 lines
6.4 KiB
Python
from django.db import transaction
|
||
from django.shortcuts import get_object_or_404
|
||
from django.utils import timezone
|
||
from drf_spectacular.utils import extend_schema
|
||
from rest_framework.exceptions import Throttled
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.views import APIView
|
||
|
||
from accounts.models import User as AccountUser
|
||
from .models import Conversation, Message, MessageFeedback
|
||
from .serializers import (
|
||
AskRequestSerializer,
|
||
AskResponseSerializer,
|
||
ConversationMessagesResponseSerializer,
|
||
FeedbackRequestSerializer,
|
||
FeedbackResponseSerializer,
|
||
)
|
||
|
||
DAILY_AI_QUOTA = 15
|
||
|
||
|
||
def generate_tutor_answer(user_question: str, grade_id, lesson_id) -> tuple[str, list[str]]:
|
||
"""
|
||
استاب پاسخ AI Tutor. اینجا محل اتصال به مدل واقعی (مثلاً Claude API از طریق
|
||
Anthropic SDK) هست؛ فعلاً برای اینکه بدون نیاز به کلید API هم بشه کل جریان
|
||
رو با Swagger تست کرد، یک پاسخ ساده و قطعی برمیگردونه.
|
||
"""
|
||
answer = (
|
||
f'این یک پاسخ نمونه (stub) برای سوال «{user_question}» است. '
|
||
'برای اتصال به مدل واقعی هوش مصنوعی، این تابع را در ai_tutor/views.py '
|
||
'جایگزین کنید (مثلاً با فراخوانی Anthropic API).'
|
||
)
|
||
follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده']
|
||
return answer, follow_ups
|
||
|
||
|
||
class AskAIView(APIView):
|
||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(request=AskRequestSerializer, responses=AskResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
||
def post(self, request):
|
||
serializer = AskRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
user = request.user
|
||
|
||
# ⚠️ اصلاح: چک و مصرفِ کوتای روزانه قبلاً race condition داشت —
|
||
# دو درخواست همزمان از همون کاربر میتونستن هر دو شمارشِ «امروز»
|
||
# رو قبل از ساختن پیامِ خودشون بخونن و هر دو رد بشن، در نتیجه از
|
||
# سقف ۱۵ سوال روزانه عبور کنن. حالا با قفلکردن ردیف کاربر
|
||
# (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر
|
||
# کاربر serialize میشه؛ درخواستهای همزمانِ همون کاربر منتظر آزاد
|
||
# شدن قفل میمونن و دیگه نمیتونن همزمان از کنار چک رد بشن.
|
||
with transaction.atomic():
|
||
AccountUser.objects.select_for_update().get(pk=user.id)
|
||
|
||
today = timezone.localdate()
|
||
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='سقف روزانهی سوال از هوش مصنوعی به پایان رسیده است.')
|
||
|
||
conversation_id = v.get('conversationId')
|
||
if conversation_id:
|
||
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id)
|
||
else:
|
||
conversation = Conversation.objects.create(
|
||
user_id=user.id,
|
||
lesson_id=v.get('lessonId') or None,
|
||
chapter_id=v.get('chapterId') or None,
|
||
grade_id=v.get('gradeId'),
|
||
)
|
||
|
||
Message.objects.create(
|
||
conversation=conversation,
|
||
role='user',
|
||
text=v['userQuestion'],
|
||
question_id=v.get('questionId') or None,
|
||
)
|
||
|
||
answer_text, follow_ups = generate_tutor_answer(
|
||
v['userQuestion'], v.get('gradeId'), v.get('lessonId')
|
||
)
|
||
assistant_message = Message.objects.create(
|
||
conversation=conversation,
|
||
role='assistant',
|
||
text=answer_text,
|
||
question_id=v.get('questionId') or None,
|
||
)
|
||
|
||
used_today += 1 # همین سوالی که الان ثبت شد هم جزو مصرف امروزه
|
||
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
|
||
|
||
data = {
|
||
'conversationId': str(conversation.id),
|
||
'messageId': str(assistant_message.id),
|
||
'answer': answer_text,
|
||
'suggestedFollowUps': follow_ups,
|
||
'remainingQuota': remaining_quota,
|
||
}
|
||
return Response(AskResponseSerializer(data).data)
|
||
|
||
|
||
class ConversationMessagesView(APIView):
|
||
"""GET /api/ai/conversations/{id}/messages — تاریخچهی مکالمه"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
||
def get(self, request, conversation_id):
|
||
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=request.user.id)
|
||
messages = conversation.messages.all()
|
||
|
||
data = {
|
||
'conversationId': str(conversation.id),
|
||
'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None,
|
||
'messages': [
|
||
{'messageId': str(m.id), 'role': m.role, 'text': m.text} for m in messages
|
||
],
|
||
}
|
||
return Response(ConversationMessagesResponseSerializer(data).data)
|
||
|
||
|
||
class MessageFeedbackView(APIView):
|
||
"""POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(
|
||
request=FeedbackRequestSerializer,
|
||
responses=FeedbackResponseSerializer,
|
||
tags=['AI Tutor (ai_tutor)'],
|
||
)
|
||
def post(self, request, message_id):
|
||
message = get_object_or_404(Message, pk=message_id, conversation__user_id=request.user.id)
|
||
serializer = FeedbackRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
|
||
MessageFeedback.objects.update_or_create(
|
||
message=message,
|
||
defaults={'is_helpful': serializer.validated_data['isHelpful']},
|
||
)
|
||
return Response(FeedbackResponseSerializer({'success': True}).data)
|