from django.db import IntegrityError, transaction from django.shortcuts import get_object_or_404 from django.utils import timezone from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, 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 AskIdempotency, 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] # ⚠️ افزوده شده: هدرِ اختیاریِ Idempotency-Key (دقیقاً همون الگویی که # payment و content/QuestionAnswerView و content/LessonCompleteView # دارن) که قبلاً برای این endpoint نه مستند بود و نه پیاده‌سازی شده # بود؛ نبودش یعنی رتری/دابل‌کلیکِ همون سوال می‌تونست یک پیامِ کاربر و # یک پیامِ دستیارِ تکراری بسازه و یک اسلاتِ کوتای روزانه‌ی اضافه مصرف # کنه. @extend_schema( request=AskRequestSerializer, responses=AskResponseSerializer, parameters=[ OpenApiParameter( 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, description=( 'کلید اختیاری idempotency (مثل payment و content). اگه ست بشه، ' 'رتری/دابل‌کلیکِ همون سوال با همون کلید، دوباره پیامِ کاربر/دستیارِ ' 'تکراری نمی‌سازه و اسلاتِ کوتای روزانه‌ی دیگری مصرف نمی‌کنه؛ به‌جاش ' 'نتیجه‌ی همون درخواستِ قبلاً پردازش‌شده برگردونده می‌شه.' ), ), ], 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 idempotency_key = request.headers.get('Idempotency-Key') or None # ⚛️🔒 اصلاح idempotency: اگه کلاینت کلید idempotency فرستاده و # قبلاً همین درخواست با همین کلید کامل پردازش شده (AskIdempotency # موجوده)، به‌جای ساختِ دوباره‌ی پیام‌ها و مصرفِ دوباره‌ی کوتا، نتیجه‌ی # همون درخواستِ قبلی برگردونده می‌شه. کوتای «باقی‌مونده» دوباره از # روی وضعیتِ فعلی محاسبه می‌شه (نه مقدارِ زمانِ اولین درخواست) چون # این فراخوانی به‌خودی‌خود چیزی مصرف نکرده. if idempotency_key: existing = AskIdempotency.objects.filter( user_id=user.id, idempotency_key=idempotency_key, ).select_related('conversation', 'user_message', 'assistant_message').first() if existing is not None: return Response(self._build_ask_response(existing).data) # ⚠️ اصلاح: چک و مصرفِ کوتای روزانه قبلاً race condition داشت — # دو درخواست هم‌زمان از همون کاربر می‌تونستن هر دو شمارشِ «امروز» # رو قبل از ساختن پیامِ خودشون بخونن و هر دو رد بشن، در نتیجه از # سقف ۱۵ سوال روزانه عبور کنن. حالا با قفل‌کردن ردیف کاربر # (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر # کاربر serialize می‌شه؛ درخواست‌های هم‌زمانِ همون کاربر منتظر آزاد # شدن قفل می‌مونن و دیگه نمی‌تونن هم‌زمان از کنار چک رد بشن. # # ⚛️ اصلاح اتمیک‌بودن: قبلاً ساختِ پیامِ کاربر داخلِ این atomic # commit می‌شد و بعد از بسته‌شدنِ تراکنش، generate_tutor_answer و # ساختِ پیامِ دستیار جدا (بدون atomic) انجام می‌شد. یعنی اگه بین # این دو مرحله خطایی پیش می‌اومد (مثلاً خطای شبکه‌ای هنگام فراخوانیِ # واقعیِ سرویسِ AI، یا خطای DB روی ساختِ پیامِ دستیار)، پیامِ کاربر # و مصرفِ کوتای روزانه‌اش برای همیشه در دیتابیس ثبت‌شده باقی # می‌موند، بدون اینکه هیچ پاسخی برایش ساخته شده باشد و بدون اینکه # کلاینت پاسخ موفقی گرفته باشد — یک «سوال یتیم» که هم کوتا را # مصرف کرده هم درخواست را از دید کاربر ناموفق نشان می‌دهد. حالا # ساختِ پیامِ کاربر، فراخوانیِ generate_tutor_answer و ساختِ پیامِ # دستیار همگی داخل یک تراکنشِ اتمیکِ واحدند: یا هر دو پیام با هم # commit می‌شن، یا (در صورتِ خطا) هیچ‌کدام، و کوتای روزانه هم در # آن صورت مصرف‌نشده باقی می‌ماند. # نکته برای آینده: وقتی generate_tutor_answer به یک فراخوانیِ # شبکه‌ایِ واقعی (مثلاً Anthropic API) وصل شد، اگر آن فراخوانی # کند/کندپاسخ باشد، بهتر است پیش از بازکردنِ این تراکنش (یا با # گرفتنِ پاسخ بیرون از قفلِ ردیفِ کاربر) انجام شود تا قفل برای مدتِ # طولانی نگه داشته نشود؛ این یک ملاحظه‌ی کارایی/همزمانی است، نه # صحتِ تراکنش، و در همین دور تغییر داده نشده. # ⚛️🔒 دفاع در عمق برای idempotency: اگه دو submit هم‌زمان دقیقاً با # همون Idempotency-Key وارد بشن (رتریِ واقعیِ شبکه‌ایِ کلاینت، درست # بعد از چکِ existing بالا و قبل از commit شدنش)، UniqueConstraintِ # سطح DB (uniq_ask_idempotency_key) جلوی ساختِ رکوردِ دومِ # AskIdempotency رو می‌گیره و کلِ این atomic block (شاملِ دو پیام و # مصرفِ کوتا) rollback می‌شه — دقیقاً همون الگویی که # QuestionAnswerView/LessonCompleteView در content و # apply_order_payment_result در payment دارن. در این حالت، به‌جای # خطا، نتیجه‌ی همون درخواستی که رقیب ثبت کرده رو idempotent # برمی‌گردونیم. try: 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'), ) user_message = 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, ) if idempotency_key: AskIdempotency.objects.create( user_id=user.id, idempotency_key=idempotency_key, conversation=conversation, user_message=user_message, assistant_message=assistant_message, ) except IntegrityError: if not idempotency_key: raise existing = AskIdempotency.objects.filter( user_id=user.id, idempotency_key=idempotency_key, ).select_related('conversation', 'user_message', 'assistant_message').first() if existing is None: raise return Response(self._build_ask_response(existing).data) 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) def _build_ask_response(self, existing: AskIdempotency) -> AskResponseSerializer: """ ساختِ همون پاسخِ AskResponseSerializer برای یک AskIdempotency از قبل ثبت‌شده (مسیرِ idempotent replay)، بدونِ ساختِ پیامِ جدید یا مصرفِ کوتا. suggestedFollowUps ذخیره نشده (فقط متنِ پاسخِ نهایی در Message.text ذخیره‌ست)، پس با فراخوانیِ دوباره‌ی همون تابعِ خالص و بدونِ اثرِ جانبیِ generate_tutor_answer (نه ساختِ پیام، نه مصرفِ کوتا) روی همون سوالِ ثبت‌شده بازسازی می‌شه؛ remainingQuota هم از روی مصرفِ *فعلیِ* امروز محاسبه می‌شه، نه لحظه‌ی درخواستِ اولیه، چون این فراخوانیِ idempotent به‌خودی‌خود چیزی مصرف نکرده. """ conversation = existing.conversation _, follow_ups = generate_tutor_answer( existing.user_message.text, conversation.grade_id, conversation.lesson_id ) 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_AI_QUOTA - used_today, 0) data = { 'conversationId': str(conversation.id), 'messageId': str(existing.assistant_message.id), 'answer': existing.assistant_message.text, 'suggestedFollowUps': follow_ups, 'remainingQuota': remaining_quota, } return AskResponseSerializer(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)