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.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 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] @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)