import logging from django.db import IntegrityError, transaction from django.http import Http404 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 APIException, Throttled from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle 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, ) logger = logging.getLogger(__name__) DAILY_AI_QUOTA = 15 class GuestAwareAIThrottle(ScopedRateThrottle): """ ⚠️ اضافه شده: ScopedRateThrottle استاندارد برای کاربرِ احرازهویت‌شده (مهمان یا نه) از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه. چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، برای هر حساب یک bucket مستقلِ throttle (و همین‌طور کوتای روزانه‌ی AskAIView که هم بر اساسِ user_id شمارش می‌شه) بگیره — یعنی نرخِ واقعیِ استفاده از این endpoint گرون‌قیمت به‌جایِ محدود به نرخِ ai_ask در ساعت، عملاً فقط به نرخِ ساختِ حساب مهمان (guest_session) محدود می‌شد. این کلاس فقط برای کاربرهای مهمان (is_guest=True) کلیدِ cache رو از pk کاربر به IP کلاینت (همون get_ident که guest_session هم استفاده می‌کنه) تغییر می‌ده؛ یعنی همه‌ی حساب‌های مهمانِ ساخته‌شده از یک IP مشترکِ throttle bucket ai_ask رو دارن و دیگه با عوض‌کردنِ حساب نمی‌شه bucket تازه گرفت. رفتارِ throttle برای کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ DAILY_AI_QUOTA و بقیه‌ی view دست‌نخورده‌ست. """ def get_cache_key(self, request, view): user = request.user if user and user.is_authenticated and getattr(user, 'is_guest', False): ident = f'guest-ip:{self.get_ident(request)}' elif user and user.is_authenticated: ident = user.pk else: ident = self.get_ident(request) return self.cache_format % {'scope': self.scope, 'ident': ident} def _is_db_ref_id(value) -> bool: """ ⚠️ افزوده شده: lessonId/chapterId/questionId در قراردادِ API به‌صورتِ رشته (CharField) پذیرفته می‌شن، اما وقتی به‌عنوانِ ارجاعِ سبکِ (لایه‌ی DB) روی Conversation/Message ذخیره می‌شن، فیلدِ مقصد IntegerField‌ه. این تابع فقط تشخیص می‌ده که آیا مقدار برای ذخیره‌سازی در همچین فیلدی امن/عددی هست یا نه؛ روی مرزِ generate_tutor_answer اثری نداره (اونجا همیشه مقدارِ خامِ اصلی پاس داده می‌شه). """ if value in (None, ''): return False return str(value).strip().lstrip('-').isdigit() class AIProviderUnavailable(APIException): """ ⚠️ افزوده شده: وقتی generate_tutor_answer به یک AI provider واقعی (OpenAI/GapGPT/...) وصل بشه، شکستِ فراخوانیِ شبکه‌ای (timeout، خطای provider و غیره) باید یک خطای واضح و قابلِ retry برای کلاینت باشه، نه یک 500 خام. جزئیاتِ فنیِ خطای اصلی در لاگ سرور ثبت می‌شه (نگاه کنید به AskAIView.post)، نه در پاسخِ کلاینت. """ status_code = 503 default_detail = 'سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً دوباره تلاش کنید.' default_code = 'ai_provider_unavailable' def generate_tutor_answer( user_question: str, grade_id, lesson_id, context=None ) -> tuple[str, list[str]]: """ مرزِ (boundary) تولیدِ پاسخِ AI Tutor — محلِ اتصال به مدل واقعی (مثلاً Claude API از طریق Anthropic SDK) در آینده. 🏗️ اصلاحِ معماریِ این دور: gradeId/lessonId که در تسکِ قبلی از این امضا حذف شده بودن، برگشتن — چون اینا metadata ای هستن که لایه‌ی RAG (که جداگانه و در آینده با Qdrant پیاده می‌شه) برای retrieve‌کردنِ حدوداً ۵ chunk مرتبط از knowledge base واقعاً بهشون نیاز داره (کنارِ خودِ سوال). یعنی امضای این تابع الان: (user_question, grade_id, lesson_id, context). - grade_id و lesson_id: مستقیماً از همون فیلدهای gradeId/lessonId که در بدنه‌ی درخواستِ POST /api/ai/ask می‌آن (v.get('gradeId')، v.get('lessonId')) به این تابع پاس داده می‌شن؛ قراردادِ API endpoint دست‌نخورده باقی می‌مونه. - context: یک پارامترِ عمومیِ اختیاریِ application-generated (رشته/شیء/ لیستِ پیام — فرمتش تصمیمِ لایه‌ی RAG در آینده‌ست) که *این* تابع صرفاً مصرف می‌کنه، بدونِ اینکه بدونه از کجا اومده (retrieval از Qdrant؟ چیزِ دیگه؟). یعنی جریانِ آینده: gradeId + lessonId + userQuestion → لایه‌ی RAG (Qdrant) → ~۵ chunkِ انتخاب‌شده به‌عنوانِ context → این تابع (لایه‌ی تولید) → provider این تابع (و provider واقعی‌ای که جایگزینِ استابِ زیر می‌شه) نباید خودش Conversation/Message رو از دیتابیس بخونه یا تاریخچه‌ی خامِ مکالمه نگه داره — مالکیتِ تاریخچه‌ی مکالمه همیشه دستِ دیتابیسِ خودمونه، نه لایه‌ی provider؛ grade_id/lesson_id صرفاً metadataی retrieval هستن، نه تاریخچه. در همین تسک، AskAIView هنوز context ای نمی‌سازه (لایه‌ی RAG/Qdrant جداگانه و بعداً پیاده می‌شه) و همیشه context=None می‌فرسته؛ پس رفتارِ فعلی (پاسخِ استابِ قطعی) بدونِ تغییر باقی می‌مونه — این فقط مرز رو برای آینده آماده می‌کنه. """ context_note = f' (با context: {context})' if context else '' answer = ( f'این یک پاسخ نمونه (stub) برای سوال «{user_question}»{context_note} است. ' 'برای اتصال به مدل واقعی هوش مصنوعی، این تابع را در ai_tutor/views.py ' 'جایگزین کنید (مثلاً با فراخوانی Anthropic API).' ) follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده'] return answer, follow_ups class AskAIView(APIView): """POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)""" permission_classes = [IsAuthenticated] # ⚠️ افزوده شده: قبلاً این endpoint هیچ throttle مخصوصی نداشت؛ DAILY_AI_QUOTA # فقط سقفِ روزانه است و جلوی burst کوتاه‌مدت (مثلاً چند ده درخواست در چند # ثانیه به generate_tutor_answer) را نمی‌گیرد. throttle_scope مجزا # (ai_ask) این burst را محدود می‌کند، بدون اینکه به کوتای روزانه/idempotency # یا منطق AI دست بزند. throttle_scope = 'ai_ask' throttle_classes = [GuestAwareAIThrottle] @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 # درخواست تکراری با همین کلید: نتیجه‌ی قبلی برگردانده می‌شود، بدون مصرف دوباره‌ی کوتا. 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) # سه فاز جدا: فاز ۱ کوتای روزانه را زیر قفل ردیف کاربر چک/رزرو می‌کند، # فاز ۲ (فراخوانی AI) کاملاً بیرون از هر قفل/تراکنشی اجرا می‌شود تا # تأخیر شبکه قفل کاربر را نگه ندارد، فاز ۳ پیام دستیار و idempotency # را ثبت می‌کند. اگر فاز ۲/۳ شکست بخورد، رزرو فاز ۱ آزاد می‌شود. 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') conversation_is_new = not conversation_id if conversation_id: # ورودی غیرعددی هم باید 404 بدهد، نه خطای خام ORM. try: conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id) except (ValueError, TypeError): raise Http404 else: # lessonId/chapterId روی Conversation فیلدهای IntegerField هستند؛ # مقدار غیرعددی نادیده گرفته می‌شود (None ذخیره می‌شود) اما مقدار # خام همچنان بدون تغییر به generate_tutor_answer می‌رسد. raw_lesson_id = v.get('lessonId') raw_chapter_id = v.get('chapterId') conversation = Conversation.objects.create( user_id=user.id, lesson_id=raw_lesson_id if _is_db_ref_id(raw_lesson_id) else None, chapter_id=raw_chapter_id if _is_db_ref_id(raw_chapter_id) else None, grade_id=v.get('gradeId'), ) raw_question_id = v.get('questionId') user_message = Message.objects.create( conversation=conversation, role='user', text=v['userQuestion'], question_id=raw_question_id if _is_db_ref_id(raw_question_id) else None, ) # قفل ردیف کاربر اینجا آزاد شده؛ فراخوانی AI بیرون از هر تراکنشی اجرا می‌شود. # context برای لایه‌ی RAG/Qdrant آینده در نظر گرفته شده؛ فعلاً None است. grade_id = v.get('gradeId') lesson_id = v.get('lessonId') context = None try: answer_text, follow_ups = generate_tutor_answer( v['userQuestion'], grade_id, lesson_id, context ) except Exception: logger.exception('generate_tutor_answer failed for user_id=%s', user.id) self._release_reserved_slot(conversation, user_message, conversation_is_new) raise AIProviderUnavailable() try: with transaction.atomic(): 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, suggested_follow_ups=follow_ups, ) 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 self._release_reserved_slot(conversation, user_message, conversation_is_new) 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 _release_reserved_slot(self, conversation, user_message, conversation_is_new): """پاک‌کردن رزرو فاز ۱ وقتی فاز بعدی شکست می‌خورد، تا نه پیام یتیم بماند و نه کوتا مصرف‌شده حساب شود.""" with transaction.atomic(): if conversation_is_new: conversation.delete() else: user_message.delete() def _build_ask_response(self, existing: AskIdempotency) -> AskResponseSerializer: """ ساختِ همون پاسخِ AskResponseSerializer برای یک AskIdempotency از قبل ثبت‌شده (مسیرِ idempotent replay)، بدونِ ساختِ پیامِ جدید یا مصرفِ کوتا. ⚠️ اصلاح: قبلاً suggestedFollowUps ذخیره نمی‌شد و اینجا با فراخوانیِ دوباره‌ی generate_tutor_answer بازسازی می‌شد. تا وقتی generate_tutor_answer یک stub خالی از اثر جانبیه بی‌ضرر بود، اما وقتی به یک AI provider واقعی وصل بشه، هر replay یک فراخوانیِ واقعیِ دیگه به provider می‌زد (هزینه/تأخیرِ تکراری، و suggestedFollowUps متفاوت در هر retry — دقیقاً برخلافِ هدفِ idempotency). حالا suggested_follow_ups مستقیماً از خودِ رکوردِ AskIdempotency (ذخیره‌شده در همون درخواستِ اولِ موفق) خونده می‌شه؛ هیچ فراخوانیِ دیگری به generate_tutor_answer در مسیرِ replay انجام نمی‌شه. answer هم مثلِ قبل از Message.text (بدونِ تغییر) خونده می‌شه؛ remainingQuota هم مثلِ قبل از روی مصرفِ *فعلیِ* امروز محاسبه می‌شه، نه لحظه‌ی درخواستِ اولیه، چون این فراخوانیِ 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_AI_QUOTA - used_today, 0) data = { 'conversationId': str(conversation.id), 'messageId': str(existing.assistant_message.id), 'answer': existing.assistant_message.text, 'suggestedFollowUps': existing.suggested_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)