Update project
This commit is contained in:
29
ai_tutor/migrations/0002_askidempotency.py
Normal file
29
ai_tutor/migrations/0002_askidempotency.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-19 11:36
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai_tutor', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AskIdempotency',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user_id', models.IntegerField(db_index=True)),
|
||||
('idempotency_key', models.CharField(max_length=100)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('assistant_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.message')),
|
||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.conversation')),
|
||||
('user_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.message')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('user_id', 'idempotency_key'), name='uniq_ask_idempotency_key')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -34,3 +34,35 @@ class MessageFeedback(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"feedback-msg{self.message_id}"
|
||||
|
||||
|
||||
class AskIdempotency(models.Model):
|
||||
"""
|
||||
⚛️🔒 اصلاح idempotency برای POST /api/ai/ask (همون الگویی که
|
||||
UserAnswer.idempotency_key/UserLessonCompletion.idempotency_key در
|
||||
content و Order.idempotency_key در payment دارن): چون نتیجهی هر
|
||||
«سوال» شاملِ دو ردیفِ Message (کاربر + دستیار) و مصرفِ یک اسلاتِ
|
||||
کوتای روزانهست (نه یک ردیفِ تکی مثلِ UserAnswer)، این رکورد پل بین
|
||||
(user, Idempotency-Key) و نتیجهی از قبل ساختهشده رو نگه میداره تا
|
||||
رتری/دابلکلیکِ همون کلید، دوباره پیام یا کوتای تکراری نسازه و بهجاش
|
||||
نتیجهی همون درخواستِ قبلی برگردونده بشه. UniqueConstraint سطح DB روی
|
||||
(user_id, idempotency_key) هم دفاع در عمق در برابر دو submit همزمانِ
|
||||
دقیقاً با همون کلیده (مشابه uniq_user_answer_idempotency_key).
|
||||
"""
|
||||
user_id = models.IntegerField(db_index=True)
|
||||
idempotency_key = models.CharField(max_length=100)
|
||||
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='+')
|
||||
user_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
|
||||
assistant_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['user_id', 'idempotency_key'],
|
||||
name='uniq_ask_idempotency_key',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"ask-idem-user{self.user_id}-{self.idempotency_key}"
|
||||
|
||||
@@ -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 — تاریخچهی مکالمه"""
|
||||
|
||||
Reference in New Issue
Block a user