Files
MathApp/ai_tutor/views.py
Zareian 42783f9ba4 tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:24:13 +03:30

706 lines
38 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
}
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 اثری نداره
(اونجا همیشه مقدارِ خامِ اصلی پاس داده می‌شه).
"""
if value in (None, ''):
return False
return str(value).strip().lstrip('-').isdigit()
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=v.get('questionId') or None,
**_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,
'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, 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] = []
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,
):
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),
}
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=v.get('questionId') or None,
**_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),
'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),
'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,
}
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} 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)