Harden auth, AI tutor, payments, and subscriptions

This commit is contained in:
2026-08-22 04:42:36 +03:30
parent f945f960c9
commit cb18c93a3e
41 changed files with 2202 additions and 595 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_tutor', '0002_askidempotency'),
]
operations = [
migrations.AddField(
model_name='askidempotency',
name='suggested_follow_ups',
field=models.JSONField(default=list),
),
]

View File

@@ -54,6 +54,16 @@ class AskIdempotency(models.Model):
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='+')
# ⚠️ اصلاح: قبلاً suggestedFollowUps هیچ‌جا ذخیره نمی‌شد و در مسیرِ
# replay (idempotent retry) دوباره با فراخوانیِ generate_tutor_answer
# بازسازی می‌شد. این فقط تا وقتی generate_tutor_answer یک stub خالی از
# اثر جانبیه بی‌ضرره؛ وقتی به یک AI provider واقعی وصل بشه، هر replay
# یک درخواستِ واقعیِ دیگه به provider می‌زنه (هزینه/تأخیرِ تکراری، و
# پیشنهادهای follow-up متفاوت در هر retry — دقیقاً برخلافِ هدفِ
# idempotency). حالا خروجیِ follow-up های همون درخواستِ اولِ موفق،
# همینجا ذخیره می‌شه تا replay بتونه بدونِ هیچ فراخوانیِ دیگری به
# generate_tutor_answer، همون مقدارِ اصلی رو برگردونه.
suggested_follow_ups = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:

View File

@@ -1,3 +1,400 @@
from django.test import TestCase
from unittest.mock import patch
# Create your tests here.
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import AskIdempotency, Conversation, Message
from .views import generate_tutor_answer
class AskIdempotencyFollowUpsTests(TestCase):
"""
🔒 تستِ رگرسیونِ فیکس: _build_ask_response قبلاً برای بازسازیِ
suggestedFollowUps دوباره generate_tutor_answer رو صدا می‌زد — که با
یک AI provider واقعی یعنی یک فراخوانیِ واقعیِ دیگه به‌ازای هر replay.
حالا suggested_follow_ups در همون درخواستِ اولِ موفق ذخیره می‌شه و
replay بدونِ هیچ فراخوانیِ دیگری به generate_tutor_answer همون مقدار
رو برمی‌گردونه.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-ask-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def _ask(self, idempotency_key):
return self.client.post(
'/api/ai/ask',
{'userQuestion': 'یک به‌علاوه یک چند می‌شود؟'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
def test_initial_request_stores_generated_follow_ups(self):
response = self._ask('ask-key-1')
self.assertEqual(response.status_code, 200)
record = AskIdempotency.objects.get(user_id=self.user.id, idempotency_key='ask-key-1')
self.assertEqual(record.suggested_follow_ups, response.data['suggestedFollowUps'])
self.assertTrue(record.suggested_follow_ups)
def test_idempotent_retry_returns_same_follow_ups_without_calling_generator_again(self):
first_response = self._ask('ask-key-2')
self.assertEqual(first_response.status_code, 200)
original_follow_ups = first_response.data['suggestedFollowUps']
# ⚠️ در مسیرِ replay، generate_tutor_answer نباید اصلاً صدا زده
# بشه؛ اگه صدا زده بشه، این patch باعثِ خطا می‌شه و تست fail
# می‌کنه.
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=AssertionError('generate_tutor_answer must not be called on idempotent replay'),
):
retry_response = self._ask('ask-key-2')
self.assertEqual(retry_response.status_code, 200)
self.assertEqual(retry_response.data['suggestedFollowUps'], original_follow_ups)
self.assertEqual(retry_response.data['answer'], first_response.data['answer'])
self.assertEqual(retry_response.data['messageId'], first_response.data['messageId'])
class AskAIGenerationFailureTests(TestCase):
"""
🔒 تستِ رگرسیونِ فیکسِ اصلیِ این تسک: جداکردنِ فراخوانیِ
generate_tutor_answer از قفلِ ردیفِ کاربر نباید رفتارِ صحتیِ سیستم رو
خراب کنه. اگه generate_tutor_answer خطا بده (مثلاً چون یک AI provider
واقعی موقتاً در دسترس نیست)، نباید:
- هیچ Message یتیمی (پیامِ کاربر بدونِ پاسخ) در دیتابیس بمونه؛
- اسلاتِ کوتای روزانه برای همیشه مصرف‌شده حساب بشه.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-ask-fail-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def _ask(self, idempotency_key=None):
headers = {}
if idempotency_key:
headers['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
return self.client.post(
'/api/ai/ask',
{'userQuestion': 'دو به‌علاوه دو چند می‌شود؟'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
**headers,
)
def test_generation_failure_leaves_no_orphan_message_and_returns_503(self):
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=ConnectionError('provider unreachable'),
):
response = self._ask()
self.assertEqual(response.status_code, 503)
self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id).count(), 0)
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 0)
def test_generation_failure_does_not_consume_quota_slot(self):
# یک سوالِ ناموفق می‌فرستیم (که نباید کوتا مصرف کنه)، بعد دقیقاً
# DAILY_AI_QUOTA سوالِ موفق پشتِ سرِ هم می‌فرستیم؛ همه باید ۲۰۰
# بگیرن، یعنی سوالِ ناموفقِ اول اسلاتی از کوتا مصرف نکرده.
from .views import DAILY_AI_QUOTA
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=ConnectionError('provider unreachable'),
):
failed_response = self._ask()
self.assertEqual(failed_response.status_code, 503)
for _ in range(DAILY_AI_QUOTA):
ok_response = self._ask()
self.assertEqual(ok_response.status_code, 200)
# این سوالِ اضافه باید رد بشه چون سقفِ ۱۵ (نه ۱۴) واقعاً پر شده.
throttled_response = self._ask()
self.assertEqual(throttled_response.status_code, 429)
def test_generation_failure_on_existing_conversation_keeps_conversation(self):
# وقتی conversationId از قبل وجود داره (متعلق به این کاربر)، در
# صورتِ شکستِ generate_tutor_answer فقط user_messageِ همین
# درخواست باید حذف بشه، نه خودِ conversation (که ممکنه پیام‌های
# دیگه‌ای هم داشته باشه).
conversation = Conversation.objects.create(user_id=self.user.id)
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمی')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمی')
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=ConnectionError('provider unreachable'),
):
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'یک سوالِ جدید', 'conversationId': str(conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 503)
self.assertTrue(Conversation.objects.filter(pk=conversation.id).exists())
# فقط دو پیامِ قدیمی باید باقی مونده باشن؛ پیامِ کاربرِ رزروشده‌ی
# همین درخواستِ ناموفق باید پاک شده باشه.
self.assertEqual(conversation.messages.count(), 2)
class AskAIConcurrentIdempotencyKeyTests(TestCase):
"""
🔒⚛️ تستِ رگرسیونِ فیکسِ این تسک برای حالتِ خیلی نادرِ رقابتِ هم‌زمانِ
دو درخواست با دقیقاً همون Idempotency-Key، بعد از اینکه فراخوانیِ AI
از زیرِ قفلِ ردیفِ کاربر بیرون اومده. این تست به‌جای threadهای واقعی
(که روی SQLite ناپایدارن)، این رقابت رو به‌صورتِ قطعی/تکرارپذیر شبیه‌سازی
می‌کنه: داخلِ side_effectِ generate_tutor_answerِ درخواستِ «اول»، یک
درخواستِ HTTP کاملاً جداگانه (با همون کلید) فرستاده می‌شه که کاملاً
پردازش می‌شه و AskIdempotency رو زودتر می‌سازه (برنده)؛ درخواستِ
«اول» بعد از برگشتن از generate_tutor_answer باید در فازِ نهایی به
IntegrityError بخوره، رزروِ خودش رو پاک کنه، و نتیجه‌ی همون برنده رو
برگردونه.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-ask-race-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def test_losing_request_cleans_up_and_returns_winners_result(self):
key = 'race-key-1'
inner_client = APIClient()
# ⚠️ چون generate_tutor_answer برای کلِ طولِ این with-block پچ
# شده، درخواستِ داخلیِ زیر هم به همین side_effect برمی‌خوره؛ برای
# جلوگیری از بازگشتِ بی‌نهایت، فقط اولین فراخوانی (درخواستِ
# بیرونی) درخواستِ داخلی رو می‌سازه؛ خودِ فراخوانیِ generate_tutor_
# answerِ درخواستِ داخلی (فراخوانیِ دوم) صرفاً یک پاسخِ قطعی
# برمی‌گردونه.
call_count = {'n': 0}
def side_effect(user_question, grade_id, lesson_id, context=None):
call_count['n'] += 1
if call_count['n'] == 1:
# این «درخواستِ داخلی» با همون idempotency key، در طولِ
# فراخوانیِ AIِ درخواستِ بیرونی (که قفلِ ردیفِ کاربر رو
# دیگه نگه نداره) کاملاً اجرا و کامل می‌شه — یعنی برنده‌ی
# رقابت می‌شه.
resp = inner_client.post(
'/api/ai/ask',
{'userQuestion': user_question},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=key,
)
assert resp.status_code == 200, resp.content
side_effect.inner_response = resp
return 'پاسخِ واقعی برای سوال', ['ادامه بده']
return 'پاسخِ دستیار برای درخواستِ داخلی', ['ادامه بده']
with patch('ai_tutor.views.generate_tutor_answer', side_effect=side_effect):
outer_response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سوالِ رقابتی'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=key,
)
inner_response = side_effect.inner_response
self.assertEqual(outer_response.status_code, 200)
# هر دو پاسخ باید دقیقاً به همون AskIdempotency (نتیجه‌ی برنده)
# اشاره کنن.
self.assertEqual(outer_response.data['messageId'], inner_response.data['messageId'])
self.assertEqual(outer_response.data['answer'], inner_response.data['answer'])
# فقط یک رکوردِ AskIdempotency برای این کلید باید وجود داشته باشه.
self.assertEqual(
AskIdempotency.objects.filter(user_id=self.user.id, idempotency_key=key).count(), 1
)
# فقط یک پیامِ کاربر و یک پیامِ دستیار باید نهایتاً باقی مونده
# باشن؛ پیامِ رزروشده‌ی درخواستِ بازنده باید پاک شده باشه (یعنی
# کوتای روزانه فقط یک بار مصرف شده، نه دوبار).
self.assertEqual(
Message.objects.filter(conversation__user_id=self.user.id, role='user').count(), 1
)
self.assertEqual(
Message.objects.filter(conversation__user_id=self.user.id, role='assistant').count(), 1
)
class AskAIConversationApiContractTests(TestCase):
"""
🔒 تستِ رگرسیونِ این تسک: اضافه‌شدنِ مرزِ context به generate_tutor_answer
نباید هیچ تغییری در قراردادِ API endpoint (POST /api/ai/ask) بده — نه
برای شروعِ مکالمه‌ی جدید (بدونِ conversationId)، نه برای ادامه‌ی
مکالمه‌ی موجود (با conversationId)، و نه در چک‌های مالکیت/احراز هویت.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-contract-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.other_user = User.objects.create(device_id='device-contract-2', is_guest=True, name='')
self.other_token = issue_access_token(self.other_user.id)
def test_no_conversation_id_creates_new_conversation(self):
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 0)
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سلام'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 200)
conversations = Conversation.objects.filter(user_id=self.user.id)
self.assertEqual(conversations.count(), 1)
self.assertEqual(response.data['conversationId'], str(conversations.first().id))
def test_existing_conversation_id_continues_same_conversation(self):
conversation = Conversation.objects.create(user_id=self.user.id)
Message.objects.create(conversation=conversation, role='user', text='سوالِ قبلی')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قبلی')
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سوالِ دوم', 'conversationId': str(conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['conversationId'], str(conversation.id))
# هنوز فقط همون یک conversation باید وجود داشته باشه؛ چیزِ جدیدی
# ساخته نشده.
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 1)
self.assertEqual(conversation.messages.count(), 4)
def test_conversation_id_belonging_to_another_user_is_rejected(self):
other_conversation = Conversation.objects.create(user_id=self.other_user.id)
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سوال', 'conversationId': str(other_conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 404)
self.assertEqual(other_conversation.messages.count(), 0)
class GenerationBoundaryContextTests(TestCase):
"""
🏗️ تستِ رگرسیونِ اصلیِ این تسک: مرزِ تولیدِ پاسخ
(generate_tutor_answer(user_question, grade_id, lesson_id, context))
باید:
- همچنان gradeId/lessonId رو بگیره (metadataِ لازم برای RAG/Qdrantِ
آینده)، دقیقاً همون‌هایی که در بدنه‌ی API درخواست اومدن؛
- بتونه یک context اپلیکیشن‌-ساخته (رشته/شیء/لیستِ پیام) هم کنارِ اون‌ها
بگیره، بدونِ اینکه بدونه اون context چطور retrieve شده؛
و AskAIView (که هنوز لایه‌ی RAG/Qdrant رو پیاده نمی‌کنه) نباید خودش
تاریخچه‌ی خامِ مکالمه رو بسازه و به این مرز بفرسته.
"""
def test_generate_tutor_answer_accepts_grade_lesson_and_application_provided_context(self):
# مرز باید grade_id/lesson_id رو (به‌عنوانِ metadataِ RAGِ آینده) و
# context رو (چه رشته باشه، چه شیء/دیکشنری، چه لیستِ پیام‌های
# retrieve-شده) به‌عنوانِ آرگومان‌هایی مستقل کنارِ سوالِ کاربر قبول
# کنه.
answer_text, follow_ups = generate_tutor_answer(
'مشتق تابع sin(x) چیست؟',
7,
'42',
context={'retrieved_snippets': ['قاعده‌ی مشتق‌گیریِ توابعِ مثلثاتی']},
)
self.assertIn('مشتق تابع sin(x) چیست؟', answer_text)
self.assertTrue(follow_ups)
# بدونِ context هم (رفتارِ فعلی/پیش‌فرض) باید عادی کار کنه؛ context
# باید کاملاً اختیاری بمونه.
answer_text_no_context, _ = generate_tutor_answer('یک سوال بدون context', None, None)
self.assertIn('یک سوال بدون context', answer_text_no_context)
def test_ask_ai_view_passes_grade_and_lesson_id_from_request_to_generation_boundary(self):
# gradeId/lessonIdِ همون درخواستِ API باید دقیقاً همون‌طور که
# فرستاده شدن به مرزِ تولید برسن — این metadataی لازم برای
# RAG/Qdrantِ آینده‌ست، نه تاریخچه.
client = APIClient()
user = User.objects.create(device_id='device-boundary-grade-1', is_guest=True, name='')
token = issue_access_token(user.id)
with patch(
'ai_tutor.views.generate_tutor_answer',
wraps=generate_tutor_answer,
) as mocked_generate:
response = client.post(
'/api/ai/ask',
{
'userQuestion': 'مشتق چیست؟',
'gradeId': 10,
'lessonId': 'lesson-55',
},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
self.assertEqual(response.status_code, 200)
mocked_generate.assert_called_once_with('مشتق چیست؟', 10, 'lesson-55', None)
def test_ask_ai_view_does_not_send_conversation_history_to_generation_boundary(self):
# این تستِ کلیدیِ این تسکه: وقتی AskAIView مرزِ تولید رو صدا می‌زنه،
# نباید هیچ تاریخچه‌ی خامِ Message/Conversation (لیستی از پیام‌های
# قبلی، یا خودِ QuerySet پیام‌ها) بهش پاس بده — چون RAG هنوز اینجا
# پیاده نشده، context باید None بمونه (فقط gradeId/lessonId/سوال
# مجازن).
client = APIClient()
user = User.objects.create(device_id='device-boundary-1', is_guest=True, name='')
token = issue_access_token(user.id)
conversation = Conversation.objects.create(user_id=user.id)
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمیِ اول')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمیِ اول')
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمیِ دوم')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمیِ دوم')
with patch(
'ai_tutor.views.generate_tutor_answer',
wraps=generate_tutor_answer,
) as mocked_generate:
response = client.post(
'/api/ai/ask',
{'userQuestion': 'سوالِ جدید', 'conversationId': str(conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
self.assertEqual(response.status_code, 200)
mocked_generate.assert_called_once()
call_args, call_kwargs = mocked_generate.call_args
all_args = list(call_args) + list(call_kwargs.values())
# آرگومانِ اول باید فقط همین سوالِ تازه باشه، نه چیزِ دیگه‌ای.
self.assertEqual(call_args[0], 'سوالِ جدید')
# هیچ آرگومانی نباید حاویِ متنِ پیام‌های قدیمی یا خودِ آبجکت‌های
# Message/Conversation باشه — یعنی هیچ تاریخچه‌ی خامی به مرزِ تولید
# پاس داده نشده.
for arg in all_args:
self.assertNotIsInstance(arg, (Message, Conversation))
if isinstance(arg, str):
self.assertNotIn('قدیمی', arg)
# context (چهارمین آرگومان) در این تسک باید None باشه (RAG/Qdrant
# هنوز پیاده نشده)؛ gradeId/lessonId هم چون در این درخواست فرستاده
# نشدن، None هستن.
context_arg = call_args[3] if len(call_args) > 3 else call_kwargs.get('context')
self.assertIsNone(context_arg)

View File

@@ -1,9 +1,12 @@
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 Throttled
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
@@ -18,17 +21,79 @@ from .serializers import (
FeedbackResponseSerializer,
)
logger = logging.getLogger(__name__)
DAILY_AI_QUOTA = 15
def generate_tutor_answer(user_question: str, grade_id, lesson_id) -> tuple[str, list[str]]:
def _is_db_ref_id(value) -> bool:
"""
استاب پاسخ AI Tutor. اینجا محل اتصال به مدل واقعی (مثلاً Claude API از طریق
Anthropic SDK) هست؛ فعلاً برای اینکه بدون نیاز به کلید API هم بشه کل جریان
رو با Swagger تست کرد، یک پاسخ ساده و قطعی برمی‌گردونه.
⚠️ افزوده شده: 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}» است. '
f'این یک پاسخ نمونه (stub) برای سوال «{user_question}»{context_note} است. '
'برای اتصال به مدل واقعی هوش مصنوعی، این تابع را در ai_tutor/views.py '
'جایگزین کنید (مثلاً با فراخوانی Anthropic API).'
)
@@ -41,12 +106,6 @@ class AskAIView(APIView):
permission_classes = [IsAuthenticated]
# ⚠️ افزوده شده: هدرِ اختیاریِ Idempotency-Key (دقیقاً همون الگویی که
# payment و content/QuestionAnswerView و content/LessonCompleteView
# دارن) که قبلاً برای این endpoint نه مستند بود و نه پیاده‌سازی شده
# بود؛ نبودش یعنی رتری/دابل‌کلیکِ همون سوال می‌تونست یک پیامِ کاربر و
# یک پیامِ دستیارِ تکراری بسازه و یک اسلاتِ کوتای روزانه‌ی اضافه مصرف
# کنه.
@extend_schema(
request=AskRequestSerializer,
responses=AskResponseSerializer,
@@ -70,12 +129,7 @@ class AskAIView(APIView):
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,
@@ -83,75 +137,64 @@ class AskAIView(APIView):
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
# برمی‌گردونیم.
# سه فاز جدا: فاز ۱ کوتای روزانه را زیر قفل ردیف کاربر چک/رزرو می‌کند،
# فاز ۲ (فراخوانی 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():
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',
@@ -166,6 +209,7 @@ class AskAIView(APIView):
conversation=conversation,
user_message=user_message,
assistant_message=assistant_message,
suggested_follow_ups=follow_ups,
)
except IntegrityError:
if not idempotency_key:
@@ -175,9 +219,10 @@ class AskAIView(APIView):
).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 # همین سوالی که الان ثبت شد هم جزو مصرف امروزه
used_today += 1 # سوال همین درخواست هم جزو مصرف امروز است
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
data = {
@@ -189,21 +234,35 @@ class AskAIView(APIView):
}
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 ذخیره نشده (فقط متنِ پاسخِ نهایی در
Message.text ذخیره‌ست)، پس با فراخوانیِ دوباره‌ی همون تابعِ خالص و
بدونِ اثرِ جانبیِ generate_tutor_answer (نه ساختِ پیام، نه مصرفِ
کوتا) روی همون سوالِ ثبت‌شده بازسازی می‌شه؛ remainingQuota هم از
روی مصرفِ *فعلیِ* امروز محاسبه می‌شه، نه لحظه‌ی درخواستِ اولیه، چون
این فراخوانیِ idempotent به‌خودی‌خود چیزی مصرف نکرده.
مصرفِ کوتا.
⚠️ اصلاح: قبلاً 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
_, 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
@@ -214,7 +273,7 @@ class AskAIView(APIView):
'conversationId': str(conversation.id),
'messageId': str(existing.assistant_message.id),
'answer': existing.assistant_message.text,
'suggestedFollowUps': follow_ups,
'suggestedFollowUps': existing.suggested_follow_ups,
'remainingQuota': remaining_quota,
}
return AskResponseSerializer(data)