The model ends a shape question with <diagram>{json}</diagram>; rag/services/diagram.py
splits it off before mathtext, validates it against a whitelist and stores it on
Message.diagram (ai_tutor.0006). It reaches the app in the SSE done event and in
conversation history. Prompt tutor-v6-diagrams; a hint next to the question (shape
words only) raised gpt-4.1-mini from 3/12 to 12/12 diagrams, 0/8 on arithmetic.
progress.0005 adds tedi (600 xp) to the companion catalog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
724 lines
39 KiB
Python
724 lines
39 KiB
Python
import json
|
||
import logging
|
||
import time
|
||
|
||
from django.conf import settings
|
||
from django.db import IntegrityError, transaction
|
||
from django.http import Http404, StreamingHttpResponse
|
||
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.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
from accounts.models import User as AccountUser
|
||
from . import provider_status
|
||
from .models import AskIdempotency, Conversation, Message, MessageFeedback
|
||
from .serializers import (
|
||
AskRequestSerializer,
|
||
AskResponseSerializer,
|
||
ConversationMessagesResponseSerializer,
|
||
FeedbackRequestSerializer,
|
||
FeedbackResponseSerializer,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
DAILY_AI_QUOTA = 15
|
||
|
||
|
||
def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool) -> dict:
|
||
"""
|
||
متادیتای بازیابی/تولید → فیلدهای Message.
|
||
|
||
همهی مقادیر به str/int امن تبدیل میشوند و هر کلیدِ غایب مقدارِ پیشفرض
|
||
میگیرد: این متادیتا از cache هم میآید و cacheهای قدیمی (که پیش از افزودنِ
|
||
این فیلدها ست شدهاند) کلیدها را ندارند — نبودشان نباید ثبتِ پیام را
|
||
بشکند، چون آنوقت خودِ پاسخِ کاربر هم از بین میرود.
|
||
"""
|
||
retrieval_meta = retrieval_meta or {}
|
||
generation_meta = generation_meta or {}
|
||
filter_value = retrieval_meta.get('filterValue')
|
||
return {
|
||
'model_name': str(generation_meta.get('model') or '')[:100],
|
||
'prompt_version': str(generation_meta.get('promptVersion') or '')[:50],
|
||
'grade_title': str(retrieval_meta.get('gradeTitle') or '')[:50],
|
||
'retrieval_collection': str(retrieval_meta.get('collection') or '')[:200],
|
||
'retrieval_filter_value': ('' if filter_value is None else str(filter_value))[:255],
|
||
'chunk_count': int(retrieval_meta.get('chunkCount') or 0),
|
||
'prompt_tokens': int(generation_meta.get('promptTokens') or 0),
|
||
'completion_tokens': int(generation_meta.get('completionTokens') or 0),
|
||
'latency_ms': int(generation_meta.get('latencyMs') or 0),
|
||
'from_cache': bool(from_cache),
|
||
# شکلِ اعتبارسنجیشده در rag.services.diagram؛ کشِ قدیمی آن را ندارد.
|
||
'diagram': generation_meta.get('diagram') if isinstance(generation_meta.get('diagram'), dict) else None,
|
||
}
|
||
|
||
|
||
class GuestAwareAIThrottle(ScopedRateThrottle):
|
||
"""
|
||
ScopedRateThrottle استاندارد برای کاربرِ احرازهویتشده
|
||
(مهمان یا نه) از request.user.pk بهعنوانِ کلیدِ throttle استفاده
|
||
میکنه. چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ
|
||
تازه (pk جدید) میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای
|
||
مهمان، برای هر حساب یک bucket مستقلِ throttle (و همینطور کوتای
|
||
روزانهی AskAIView که هم بر اساسِ user_id شمارش میشه) بگیره — یعنی
|
||
نرخِ واقعیِ استفاده از این endpoint گرونقیمت بهجایِ محدود به نرخِ
|
||
ai_ask در ساعت، عملاً فقط به نرخِ ساختِ حساب مهمان (guest_session)
|
||
محدود میشد.
|
||
|
||
این کلاس فقط برای کاربرهای مهمان (is_guest=True) کلیدِ cache رو از
|
||
pk کاربر به IP کلاینت (همون get_ident که guest_session هم استفاده
|
||
میکنه) تغییر میده؛ یعنی همهی حسابهای مهمانِ ساختهشده از یک IP
|
||
مشترکِ throttle bucket ai_ask رو دارن و دیگه با عوضکردنِ حساب
|
||
نمیشه bucket تازه گرفت. رفتارِ throttle برای کاربرهای غیرمهمان
|
||
(ثبتنامشده با شماره) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی
|
||
میمونه؛ منطقِ DAILY_AI_QUOTA و بقیهی view دستنخوردهست.
|
||
"""
|
||
|
||
def get_cache_key(self, request, view):
|
||
user = request.user
|
||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||
ident = f'guest-ip:{self.get_ident(request)}'
|
||
elif user and user.is_authenticated:
|
||
ident = user.pk
|
||
else:
|
||
ident = self.get_ident(request)
|
||
|
||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||
|
||
|
||
def _is_db_ref_id(value) -> bool:
|
||
"""
|
||
⚠️ افزوده شده: lessonId/chapterId/questionId در قراردادِ API بهصورتِ
|
||
رشته (CharField) پذیرفته میشن، اما وقتی بهعنوانِ ارجاعِ سبکِ (لایهی
|
||
DB) روی Conversation/Message ذخیره میشن، فیلدِ مقصد IntegerFieldه.
|
||
این تابع فقط تشخیص میده که آیا مقدار برای ذخیرهسازی در همچین
|
||
فیلدی امن/عددی هست یا نه؛ روی مرزِ generate_tutor_answer اثری نداره
|
||
(اونجا همیشه مقدارِ خامِ اصلی پاس داده میشه).
|
||
|
||
پیامِ دستیار هم از همین مسیر (`_db_ref_or_none`) رد میشود؛ پیش از
|
||
۲۰۲۶-۰۹-۲۵ نمیشد و questionIdِ غیرعددی *بعد از* تولیدِ جواب ۵۰۰ میداد و
|
||
سهمِ روزانه مصرفشده میماند.
|
||
"""
|
||
if value in (None, ''):
|
||
return False
|
||
return str(value).strip().lstrip('-').isdigit()
|
||
|
||
|
||
def _db_ref_or_none(value):
|
||
"""مقدارِ عددی برای ستونِ IntegerField، وگرنه None (نه خطای ORM)."""
|
||
return value if _is_db_ref_id(value) else None
|
||
|
||
|
||
def _conversation_history(conversation, before_message) -> list[dict]:
|
||
"""
|
||
چند پیامِ آخرِ همین گفتگو، پیش از سوالِ تازه، به ترتیبِ زمان.
|
||
|
||
بدونِ این، هر سوال جدا از قبلیها جواب داده میشد: «نفهمیدم، سادهتر بگو»
|
||
برای مدل هیچ مرجعی نداشت.
|
||
"""
|
||
from rag.services.generation import HISTORY_TURNS
|
||
|
||
rows = list(
|
||
Message.objects.filter(conversation=conversation, id__lt=before_message.id)
|
||
.order_by('-id').values('role', 'text')[:HISTORY_TURNS]
|
||
)
|
||
return list(reversed(rows))
|
||
|
||
|
||
def _retrieval_query(question: str, history: list[dict], focus: str = '') -> str:
|
||
"""
|
||
متنی که embedding میشود. «یه مثال دیگه بزن» بهتنهایی به هیچ تکهای از
|
||
کتاب نزدیک نیست؛ سوالِ قبلیِ کاربر (یا سوالِ تمرینیِ اپ) موضوع را معلوم میکند.
|
||
"""
|
||
if focus:
|
||
return f'{focus}\n{question}'
|
||
previous = next((t['text'] for t in reversed(history) if t['role'] == 'user'), '')
|
||
return f'{previous}\n{question}' if previous else question
|
||
|
||
|
||
def _question_focus(v: dict, user) -> str:
|
||
"""اگر اپ questionId فرستاده، متنِ همان سوال و جوابِ دانشآموز (rag.generation)."""
|
||
question_id = v.get('questionId')
|
||
if not _is_db_ref_id(question_id):
|
||
return ''
|
||
from rag.services.generation import question_focus
|
||
|
||
return question_focus(question_id, user.id)
|
||
|
||
|
||
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.
|
||
|
||
امضا: (user_question, grade_id, lesson_id, context). context یک پارامترِ
|
||
عمومیِ اختیاریِ application-generated است که این تابع صرفاً مصرف میکنه،
|
||
بدونِ اینکه بدونه از کجا retrieve شده. جریان:
|
||
|
||
gradeId + subject + lessonNumber + userQuestion → لایهی RAG (اپ rag،
|
||
Qdrant) → ~۴ chunkِ انتخابشده بهعنوانِ context → این تابع (لایهی
|
||
تولید) → provider (OpenAI از طریقِ GapGPT)
|
||
|
||
AskAIView خودش retrieval را انجام میده و نتیجه را بهعنوانِ context
|
||
(لیستی از dictهای {title, summary, score}) پاس میده؛ این تابع فقط
|
||
تولید میکنه. مالکیتِ تاریخچهی مکالمه همیشه دستِ دیتابیسِ خودمونه، نه
|
||
provider.
|
||
|
||
اگه RAG پیکربندی نشده باشه (نبودِ OPENAI_API_KEY/QDRANT_API_KEY یا
|
||
RAG_FORCE_MOCK)، همون پاسخِ stubِ قطعیِ قبلی برمیگرده تا اجرای بدونِ
|
||
کلید و تستها دستنخورده کار کنن.
|
||
"""
|
||
from rag.services.config import rag_is_configured
|
||
|
||
if rag_is_configured():
|
||
from rag.services.generation import generate_answer
|
||
|
||
context_chunks = context if isinstance(context, list) else None
|
||
# استثناهای شبکه/provider اینجا خفه نمیشن؛ AskAIView اونها رو به
|
||
# 503 AIProviderUnavailable تبدیل میکنه (و اسلاتِ رزروشده رو آزاد).
|
||
# این تابع قراردادِ دوتاییاش را نگه میدارد؛ متادیتای تولید فقط در
|
||
# مسیرِ اصلیِ AskAIView (که آن را ذخیره میکند) لازم است.
|
||
answer, follow_ups, _meta = generate_answer(user_question, context_chunks)
|
||
return answer, follow_ups
|
||
|
||
context_note = f' (با context: {context})' if context else ''
|
||
answer = (
|
||
f'این یک پاسخ نمونه (stub) برای سوال «{user_question}»{context_note} است. '
|
||
'برای اتصال به مدل واقعی هوش مصنوعی، OPENAI_API_KEY و QDRANT_API_KEY '
|
||
'را در .env تنظیم کنید (لایهی RAG در اپ rag).'
|
||
)
|
||
follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده']
|
||
return answer, follow_ups
|
||
|
||
|
||
class AskAIView(APIView):
|
||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
# DAILY_AI_QUOTA فقط سقفِ روزانهست و جلوی burstِ کوتاهمدت رو نمیگیره؛
|
||
# این throttle_scope مجزا اون burst رو محدود میکنه.
|
||
throttle_scope = 'ai_ask'
|
||
throttle_classes = [GuestAwareAIThrottle]
|
||
|
||
@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'),
|
||
subject=(v.get('subject') or '').strip(),
|
||
lesson_number=v.get('lessonNumber'),
|
||
)
|
||
|
||
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 بیرون از هر تراکنشی اجرا میشود.
|
||
# لایهی RAG (اپ rag) اینجا context را میسازد: پرسش → embedding →
|
||
# جستجوی Qdrant (محدود به subject+grade+lessonNumber) → چند خلاصه.
|
||
# اگر RAG پیکربندی نشده باشد context=None میماند و generate_tutor_answer
|
||
# همان پاسخِ stub را میدهد. خطای شبکه/provider در همین try به 503
|
||
# تبدیل و اسلاتِ رزروشده آزاد میشود.
|
||
# مقادیرِ ذخیرهشده روی مکالمه هم fallbackاند: در پیامِ دوم به بعد، اپ
|
||
# فقط conversationId میفرستد و دیگر lessonId/gradeId نمیآید.
|
||
grade_id = v.get('gradeId') or conversation.grade_id
|
||
lesson_id = v.get('lessonId') or conversation.lesson_id
|
||
subject = (v.get('subject') or conversation.subject or '').strip()
|
||
grade_title = (v.get('grade') or '').strip()
|
||
lesson_number = v.get('lessonNumber')
|
||
if lesson_number is None:
|
||
lesson_number = conversation.lesson_number
|
||
|
||
# اگر اپ فقط lessonId (یا فقط conversationId) داده — حالتِ طبیعی، چون
|
||
# میداند دانشآموز روی کدام درس است نه اینکه نامِ collectionِ Qdrant
|
||
# چیست — خودمان subject/پایه/فصل را از زنجیرهی
|
||
# Lesson→Node→Unit→Grade درمیآوریم.
|
||
#
|
||
# ⚠️ شرط عمداً `grade_title` را هم میبیند: پیشتر فقط subject و
|
||
# lesson_number را چک میکرد، و چون Conversation فیلدی برای عنوانِ پایه
|
||
# ندارد، از پیامِ دومِ هر مکالمه grade_title خالی میماند و
|
||
# resolve_target هیچ collectionی نمیساخت — یعنی فقط جوابِ اول به کتاب
|
||
# مستند بود و بقیه بیسروصدا عمومی میشدند.
|
||
if lesson_id and not (subject and grade_title and lesson_number is not None):
|
||
from rag.services.lesson_context import resolve_from_lesson
|
||
|
||
derived = resolve_from_lesson(lesson_id)
|
||
subject = subject or derived.get('subject', '')
|
||
grade_title = grade_title or derived.get('gradeTitle', '')
|
||
if lesson_number is None:
|
||
lesson_number = derived.get('chapter')
|
||
# تا آخرِ همین مکالمه دوباره حساب نشود.
|
||
if subject and not conversation.subject:
|
||
conversation.subject = subject
|
||
conversation.lesson_number = lesson_number
|
||
conversation.save(update_fields=['subject', 'lesson_number'])
|
||
|
||
# نه درسی آمده نه subject — حالتِ عادیِ صفحهی «از پی بپرس» که از نوارِ
|
||
# پایین باز میشود. پایهی ذخیرهشدهی خودِ کاربر کتاب را معلوم میکند؛
|
||
# بدونِ این، جوابهای آن صفحه هیچوقت به کتاب مستند نمیشدند.
|
||
if not subject:
|
||
from rag.services.lesson_context import resolve_from_grade
|
||
|
||
grade_id = grade_id or getattr(user, 'grade_id', None)
|
||
derived = resolve_from_grade(grade_id)
|
||
subject = derived.get('subject', '')
|
||
grade_title = grade_title or derived.get('gradeTitle', '')
|
||
if subject and not conversation.subject:
|
||
conversation.subject = subject
|
||
conversation.grade_id = conversation.grade_id or grade_id
|
||
conversation.save(update_fields=['subject', 'grade_id'])
|
||
|
||
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندیشده.
|
||
# قراردادِ JSONِ غیراستریم دستنخورده میماند؛ Swagger که stream
|
||
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
||
if v.get('stream'):
|
||
from rag.services.config import rag_is_configured
|
||
|
||
if rag_is_configured():
|
||
return self._streaming_response(
|
||
request=request, v=v, user=user, conversation=conversation,
|
||
user_message=user_message, conversation_is_new=conversation_is_new,
|
||
idempotency_key=idempotency_key, used_today=used_today,
|
||
subject=subject, grade_id=grade_id, grade_title=grade_title,
|
||
lesson_number=lesson_number,
|
||
)
|
||
|
||
used_chunks: list[dict] = []
|
||
retrieval_meta: dict = {}
|
||
generation_meta: dict = {}
|
||
answer_from_cache = False
|
||
context = None
|
||
try:
|
||
from rag.services.config import rag_is_configured
|
||
|
||
if rag_is_configured():
|
||
from rag.services import answer_cache
|
||
from rag.services.generation import generate_answer
|
||
from rag.services.retrieval import resolve_target, retrieve_context
|
||
|
||
history = _conversation_history(conversation, user_message)
|
||
focus = _question_focus(v, user)
|
||
cacheable = answer_cache.cacheable(history) and not focus
|
||
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
||
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
|
||
cached = answer_cache.get(cache_key) if cacheable else None
|
||
if cached is not None:
|
||
answer_text = cached['answer']
|
||
follow_ups = list(cached['followUps'])
|
||
used_chunks = cached['usedChunks']
|
||
retrieval_meta = cached.get('retrievalMeta') or {}
|
||
generation_meta = cached.get('generationMeta') or {}
|
||
answer_from_cache = True
|
||
else:
|
||
used_chunks, retrieval_meta = retrieve_context(
|
||
_retrieval_query(v['userQuestion'], history, focus),
|
||
subject=subject, grade_id=grade_id,
|
||
grade_title=grade_title, lesson_number=lesson_number,
|
||
)
|
||
used_chunks = [
|
||
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||
for c in used_chunks
|
||
]
|
||
answer_text, follow_ups, generation_meta = generate_answer(
|
||
v['userQuestion'], used_chunks or None,
|
||
retrieval_meta.get('gradeTitle') or grade_title or '',
|
||
history=history, focus=focus,
|
||
)
|
||
provider_status.record_success()
|
||
if cacheable:
|
||
answer_cache.set(cache_key, {
|
||
'answer': answer_text, 'followUps': follow_ups,
|
||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||
'generationMeta': generation_meta,
|
||
})
|
||
else:
|
||
answer_text, follow_ups = generate_tutor_answer(
|
||
v['userQuestion'], grade_id, lesson_id, context
|
||
)
|
||
except Exception as exc:
|
||
logger.exception('generate_tutor_answer failed for user_id=%s', user.id)
|
||
provider_status.record_failure(exc)
|
||
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=_db_ref_or_none(v.get('questionId')),
|
||
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||
)
|
||
|
||
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,
|
||
'diagram': assistant_message.diagram,
|
||
'usedChunks': [
|
||
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||
for c in used_chunks
|
||
],
|
||
}
|
||
# فقط برای بازبینیِ داخلیِ خودمان (نه کاربر): از کدام collection/فصل
|
||
# چانکها برداشته شد. فقط وقتی DEBUG روشن است در پاسخ میآید.
|
||
if settings.DEBUG and retrieval_meta:
|
||
data['retrievalDebug'] = {
|
||
'collection': retrieval_meta.get('collection'),
|
||
'filterKey': retrieval_meta.get('filterKey'),
|
||
'chapterOrLesson': retrieval_meta.get('filterValue'),
|
||
'gradeTitle': retrieval_meta.get('gradeTitle'),
|
||
'chunkCount': retrieval_meta.get('chunkCount'),
|
||
'chunkTitles': [c.get('title', '') for c in used_chunks],
|
||
}
|
||
return Response(AskResponseSerializer(data).data)
|
||
|
||
def _streaming_response(self, *, request, v, user, conversation, user_message,
|
||
conversation_is_new, idempotency_key, used_today,
|
||
subject, grade_id, grade_title, lesson_number):
|
||
"""
|
||
نسخهی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی
|
||
پاسخ توکنبهتوکن استریم میشه. رویدادها:
|
||
event: chunk data: {"delta": "..."}
|
||
event: done data: {conversationId, messageId, remainingQuota, diagram, usedChunks[, retrievalDebug]}
|
||
event: error data: {"detail": "..."}
|
||
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
|
||
"""
|
||
from rag.services import answer_cache
|
||
from rag.services.generation import (
|
||
PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer,
|
||
)
|
||
from rag.services.retrieval import resolve_target, retrieve_context
|
||
|
||
def _sse(event: str, payload: dict) -> str:
|
||
return f'event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n'
|
||
|
||
def _gen():
|
||
nonlocal used_today
|
||
generation_meta: dict = {}
|
||
answer_from_cache = False
|
||
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
||
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
|
||
try:
|
||
history = _conversation_history(conversation, user_message)
|
||
focus = _question_focus(v, user)
|
||
cacheable = answer_cache.cacheable(history) and not focus
|
||
cached = answer_cache.get(cache_key) if cacheable else None
|
||
if cached is not None:
|
||
answer_text = cached['answer']
|
||
follow_ups = list(cached['followUps'])
|
||
used_chunks = cached['usedChunks']
|
||
retrieval_meta = cached.get('retrievalMeta') or {}
|
||
generation_meta = cached.get('generationMeta') or {}
|
||
answer_from_cache = True
|
||
yield _sse('chunk', {'delta': answer_text})
|
||
else:
|
||
used_chunks, retrieval_meta = retrieve_context(
|
||
_retrieval_query(v['userQuestion'], history, focus),
|
||
subject=subject, grade_id=grade_id,
|
||
grade_title=grade_title, lesson_number=lesson_number,
|
||
)
|
||
used_chunks = [
|
||
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||
for c in used_chunks
|
||
]
|
||
parts: list[str] = []
|
||
stream_result: dict = {}
|
||
started = time.monotonic()
|
||
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
|
||
for delta in stream_answer(
|
||
v['userQuestion'], used_chunks or None, effective_grade,
|
||
history=history, focus=focus, result=stream_result,
|
||
):
|
||
parts.append(delta)
|
||
yield _sse('chunk', {'delta': delta})
|
||
provider_status.record_success()
|
||
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||
follow_ups = default_follow_ups()
|
||
# مسیرِ استریم usage برنمیگرداند (پاسخ توکنبهتوکن است)،
|
||
# پس فقط چیزی را ثبت میکنیم که واقعاً میدانیم؛ توکنها صفر
|
||
# میمانند و گزارشِ هزینه هم همین را نشان میدهد.
|
||
generation_meta = {
|
||
'model': chat_model_for(effective_grade),
|
||
'promptVersion': PROMPT_VERSION,
|
||
'latencyMs': int((time.monotonic() - started) * 1000),
|
||
'hadContext': bool(used_chunks),
|
||
'diagram': stream_result.get('diagram'),
|
||
}
|
||
if cacheable:
|
||
answer_cache.set(cache_key, {
|
||
'answer': answer_text, 'followUps': follow_ups,
|
||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||
'generationMeta': generation_meta,
|
||
})
|
||
except Exception as exc:
|
||
logger.exception('stream generate failed for user_id=%s', user.id)
|
||
provider_status.record_failure(exc)
|
||
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||
yield _sse('error', {'detail': AIProviderUnavailable.default_detail})
|
||
return
|
||
|
||
# فاز ۳: ثبتِ پیامِ دستیار + idempotency (همون منطقِ post()).
|
||
try:
|
||
with transaction.atomic():
|
||
assistant_message = Message.objects.create(
|
||
conversation=conversation, role='assistant', text=answer_text,
|
||
question_id=_db_ref_or_none(v.get('questionId')),
|
||
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||
)
|
||
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:
|
||
existing = AskIdempotency.objects.filter(
|
||
user_id=user.id, idempotency_key=idempotency_key,
|
||
).select_related('assistant_message').first() if idempotency_key else None
|
||
if existing is None:
|
||
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||
yield _sse('error', {'detail': AIProviderUnavailable.default_detail})
|
||
return
|
||
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||
yield _sse('done', {
|
||
'conversationId': str(existing.conversation_id),
|
||
'messageId': str(existing.assistant_message_id),
|
||
'suggestedFollowUps': existing.suggested_follow_ups,
|
||
'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0),
|
||
'diagram': existing.assistant_message.diagram,
|
||
'usedChunks': [],
|
||
})
|
||
return
|
||
|
||
used_today += 1
|
||
done = {
|
||
'conversationId': str(conversation.id),
|
||
'messageId': str(assistant_message.id),
|
||
'suggestedFollowUps': follow_ups,
|
||
'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0),
|
||
'diagram': assistant_message.diagram,
|
||
'usedChunks': [
|
||
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||
for c in used_chunks
|
||
],
|
||
}
|
||
if settings.DEBUG and retrieval_meta:
|
||
done['retrievalDebug'] = {
|
||
'collection': retrieval_meta.get('collection'),
|
||
'filterKey': retrieval_meta.get('filterKey'),
|
||
'chapterOrLesson': retrieval_meta.get('filterValue'),
|
||
'gradeTitle': retrieval_meta.get('gradeTitle'),
|
||
'chunkCount': retrieval_meta.get('chunkCount'),
|
||
'chunkTitles': [c.get('title', '') for c in used_chunks],
|
||
}
|
||
yield _sse('done', done)
|
||
|
||
response = StreamingHttpResponse(_gen(), content_type='text/event-stream')
|
||
response['Cache-Control'] = 'no-cache'
|
||
response['X-Accel-Buffering'] = 'no'
|
||
return response
|
||
|
||
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,
|
||
'diagram': existing.assistant_message.diagram,
|
||
}
|
||
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 = reversed(list(conversation.messages.order_by('-id')[:200]))
|
||
|
||
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, 'diagram': m.diagram}
|
||
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)
|
||
|
||
# `reason` فقط وقتی نوشته میشود که در همین درخواست آمده باشد: کاربری
|
||
# که رأیش را عوض میکند معمولاً دلیل نمینویسد، و پیشفرضِ '' متنِ
|
||
# قبلیاش را پاک میکرد — یعنی مفیدترین بخشِ فیدبک از دست میرفت.
|
||
defaults = {'is_helpful': serializer.validated_data['isHelpful']}
|
||
if 'reason' in serializer.validated_data:
|
||
defaults['reason'] = serializer.validated_data['reason']
|
||
MessageFeedback.objects.update_or_create(message=message, defaults=defaults)
|
||
return Response(FeedbackResponseSerializer({'success': True}).data)
|