263 lines
15 KiB
Python
263 lines
15 KiB
Python
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)
|