Initial commit
This commit is contained in:
147
ai_tutor/views.py
Normal file
147
ai_tutor/views.py
Normal file
@@ -0,0 +1,147 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user