The Swagger header was a list of features. That is what the project does, not what a caller needs. It now opens with the three things anyone integrating against this API asks first: how do I get a token, what does an error look like, and when do I get rate limited -- plus the field-naming and date conventions, and a note that the schema is the contract and clients should be generated from it rather than written from memory. Swagger UI and Redoc both render Markdown, so it is structured rather than one long paragraph, which also fixes how badly the old mixed Persian/Latin line wrapped right-to-left. Two defects surfaced while checking the rendered schema: - /api/health was absent from the schema entirely. The trailing-slash alias was excluded with extend_schema(exclude=True)(HealthView), but that decorator mutates the class, and both routes pointed at the same class, so the canonical route disappeared with it. A subclass now carries the exclusion, and the endpoint is back, exactly once. Endpoint count is 56, not 55. - One endpoint was tagged 'محتوا (content)' while every other content endpoint used 'Curriculum (content)', which split the group into two sections. Tags are now declared with descriptions and ordered by the path a real user takes, rather than alphabetically. Comment pass over the codebase, keeping the substance and dropping the noise: the "⚠️ اضافه شده" prefixes dated comments against a moment no current reader shares; "فاز ۲ / بخش ۴" referenced a plan that no longer exists and only repeated the app name on the next line; "فاز اول - ۴ از ۱۱ اپ" had drifted (there are fourteen). INSTALLED_APPS is now grouped by responsibility. The TEST_LOGIN_PHONES block in settings shrank from 29 lines to 8 now that the same explanation lives in docs/operations.md and .env.example -- a comment beside the code should carry what a code reader needs, not a copy of the runbook. Checked mechanically that no comment references an identifier that no longer exists: zero. The dated measurements in rag/services/ingest.py and generate_questions_from_chunks.py were deliberately left alone -- a measurement carrying the date it was taken is the useful kind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
635 lines
35 KiB
Python
635 lines
35 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 .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()
|
||
|
||
|
||
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'])
|
||
|
||
# --- مسیرِ استریم (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
|
||
|
||
_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 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(
|
||
v['userQuestion'], 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 '',
|
||
)
|
||
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:
|
||
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,
|
||
**_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, 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:
|
||
cached = answer_cache.get(cache_key)
|
||
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(
|
||
v['userQuestion'], 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()
|
||
for delta in stream_answer(
|
||
v['userQuestion'], used_chunks or None,
|
||
retrieval_meta.get('gradeTitle') or grade_title or '',
|
||
):
|
||
parts.append(delta)
|
||
yield _sse('chunk', {'delta': delta})
|
||
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||
follow_ups = default_follow_ups()
|
||
# مسیرِ استریم usage برنمیگرداند (پاسخ توکنبهتوکن است)،
|
||
# پس فقط چیزی را ثبت میکنیم که واقعاً میدانیم؛ توکنها صفر
|
||
# میمانند و گزارشِ هزینه هم همین را نشان میدهد.
|
||
generation_meta = {
|
||
'model': settings.OPENAI_CHAT_MODEL,
|
||
'promptVersion': PROMPT_VERSION,
|
||
'latencyMs': int((time.monotonic() - started) * 1000),
|
||
'hadContext': bool(used_chunks),
|
||
}
|
||
answer_cache.set(cache_key, {
|
||
'answer': answer_text, 'followUps': follow_ups,
|
||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||
'generationMeta': generation_meta,
|
||
})
|
||
except Exception:
|
||
logger.exception('stream generate failed for user_id=%s', user.id)
|
||
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)
|