Harden auth, AI tutor, payments, and subscriptions
This commit is contained in:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user