322 lines
17 KiB
Python
322 lines
17 KiB
Python
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 APIException, 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,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
DAILY_AI_QUOTA = 15
|
||
|
||
|
||
def _is_db_ref_id(value) -> bool:
|
||
"""
|
||
⚠️ افزوده شده: 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}»{context_note} است. '
|
||
'برای اتصال به مدل واقعی هوش مصنوعی، این تابع را در ai_tutor/views.py '
|
||
'جایگزین کنید (مثلاً با فراخوانی Anthropic API).'
|
||
)
|
||
follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده']
|
||
return answer, follow_ups
|
||
|
||
|
||
class AskAIView(APIView):
|
||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@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
|
||
|
||
# درخواست تکراری با همین کلید: نتیجهی قبلی برگردانده میشود، بدون مصرف دوبارهی کوتا.
|
||
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)
|
||
|
||
# سه فاز جدا: فاز ۱ کوتای روزانه را زیر قفل ردیف کاربر چک/رزرو میکند،
|
||
# فاز ۲ (فراخوانی 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():
|
||
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,
|
||
suggested_follow_ups=follow_ups,
|
||
)
|
||
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
|
||
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||
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 _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 ذخیره نمیشد و اینجا با
|
||
فراخوانیِ دوبارهی 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
|
||
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': existing.suggested_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)
|