Update project

This commit is contained in:
2026-08-19 17:15:55 +03:30
parent 02d40e1238
commit f945f960c9
25 changed files with 1092 additions and 287 deletions

View File

@@ -1,14 +1,15 @@
from django.db import transaction
from django.db import IntegrityError, transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.utils import extend_schema
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 Conversation, Message, MessageFeedback
from .models import AskIdempotency, Conversation, Message, MessageFeedback
from .serializers import (
AskRequestSerializer,
AskResponseSerializer,
@@ -40,12 +41,47 @@ class AskAIView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(request=AskRequestSerializer, responses=AskResponseSerializer, tags=['AI Tutor (ai_tutor)'])
# ⚠️ افزوده شده: هدرِ اختیاریِ 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 داشت —
# دو درخواست هم‌زمان از همون کاربر می‌تونستن هر دو شمارشِ «امروز»
@@ -54,43 +90,92 @@ class AskAIView(APIView):
# (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر
# کاربر serialize می‌شه؛ درخواست‌های هم‌زمانِ همون کاربر منتظر آزاد
# شدن قفل می‌مونن و دیگه نمی‌تونن هم‌زمان از کنار چک رد بشن.
with transaction.atomic():
AccountUser.objects.select_for_update().get(pk=user.id)
#
# ⚛️ اصلاح اتمیک‌بودن: قبلاً ساختِ پیامِ کاربر داخلِ این 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='سقف روزانه‌ی سوال از هوش مصنوعی به پایان رسیده است.')
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'),
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,
)
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,
)
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)
@@ -104,6 +189,36 @@ class AskAIView(APIView):
}
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 — تاریخچه‌ی مکالمه"""