- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
(403 node_locked, same rule as /api/home) and a user who has not answered every question
(409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
anyone else finished any island, locked or not, and finishing a locked island's lessons
unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
861 lines
47 KiB
Python
861 lines
47 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 (
|
||
QuotaResponseSerializer,
|
||
AskRequestSerializer,
|
||
AskResponseSerializer,
|
||
ConversationMessagesResponseSerializer,
|
||
FeedbackRequestSerializer,
|
||
FeedbackResponseSerializer,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
def _learner_age(user) -> int | None:
|
||
"""سنِ کاربر از پروفایلش (accounts)؛ لحنِ دستیار برای ۱۸+ بزرگسالانه است."""
|
||
from accounts.jalali import age_of
|
||
from accounts.models import Profile
|
||
|
||
profile = Profile.objects.filter(user_id=user.id).only('birth_date', 'birth_year').first()
|
||
return age_of(profile.birth_date, profile.birth_year) if profile else None
|
||
|
||
|
||
def _cache_grade(grade_title: str, age: int | None) -> str:
|
||
"""پایه در کلیدِ کشِ جواب، با لحن: جوابِ بزرگسالانه به کودک سرو نشود و برعکس."""
|
||
from rag.services.generation import tone_for
|
||
|
||
return f'{grade_title}|adult' if tone_for(grade_title, age) == 'adult' else grade_title
|
||
|
||
|
||
def quota_state(user_id: int) -> dict:
|
||
"""
|
||
{'remainingQuota', 'credits'}: سوالهای رایگانِ باقیِ امروز و اعتبارِ بسته، هر بار تازه از
|
||
دیتابیس. همهی پاسخها از همین میخوانند تا شمارندهی اپ هیچوقت از موجودیِ واقعی جدا نیفتد.
|
||
"""
|
||
from subscription.services import credit_balance
|
||
|
||
used_free = Message.objects.filter(
|
||
conversation__user_id=user_id, role='user', created_at__date=timezone.localdate(), charge='free',
|
||
).count()
|
||
return {'remainingQuota': max(daily_quota(user_id) - used_free, 0), 'credits': credit_balance(user_id)}
|
||
|
||
|
||
def _uncharge(user_message) -> None:
|
||
"""سوالی که به API نرسید (جوابِ کششده): نه از سهمیه کم شود نه از اعتبار."""
|
||
from subscription.services import refund_credit
|
||
|
||
if user_message.charge == 'credit':
|
||
refund_credit(user_message.id)
|
||
if user_message.charge in ('free', 'credit'):
|
||
user_message.charge = 'none'
|
||
user_message.save(update_fields=['charge'])
|
||
|
||
|
||
def daily_quota(user_id: int) -> int:
|
||
"""سقفِ سوالِ روزانهی این کاربر؛ رایگان یا از پلنِ اشتراکش (subscription.services)."""
|
||
from subscription.services import ai_daily_quota
|
||
|
||
return ai_daily_quota(user_id)
|
||
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
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
|
||
# isascii: '²'.isdigit() هم True است ولی int('²') خطا میدهد.
|
||
text = str(value).strip().lstrip('-')
|
||
return text.isascii() and text.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 AIDailyCapReached(APIException):
|
||
status_code = 503
|
||
default_detail = 'دستیار امروز به سقفِ استفاده رسیده؛ لطفاً فردا دوباره بپرس.'
|
||
default_code = 'ai_daily_cap'
|
||
|
||
|
||
def _check_global_daily_cap() -> None:
|
||
"""
|
||
سقفِ روزانهی کلِ سامانه (AI_GLOBAL_DAILY_MAX): سوالهای امروز که به مدل رفتهاند یا با کش
|
||
برگشتهاند ولی هنوز «free»/«credit» ماندهاند. در دیتابیس شمرده میشود، پس بینِ workerها
|
||
مشترک است. داخلِ همان تراکنشِ IMMEDIATEِ فازِ ۱ صدا زده میشود؛ روی Postgres دو درخواستِ
|
||
همزمان ممکن است هر دو از لبه رد شوند (چند سوال بیشتر از سقف، نه بیشتر).
|
||
"""
|
||
from django.conf import settings
|
||
|
||
start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0)
|
||
used = Message.objects.filter(role='user', created_at__gte=start, charge__in=('free', 'credit')).count()
|
||
if used >= settings.AI_GLOBAL_DAILY_MAX:
|
||
logger.error('AI global daily cap reached: cap=%s used=%s', settings.AI_GLOBAL_DAILY_MAX, used)
|
||
raise AIDailyCapReached()
|
||
|
||
|
||
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 پیکربندی نشده باشه، پاسخِ stub فقط در DEBUG یا با RAG_FORCE_MOCK
|
||
(تستها) برمیگرده. بیرون از آن خطا میدهد و AskAIView آن را ۵۰۳ میکند.
|
||
تا ۲۰۲۶-۱۰-۰۳ stub در پروداکشن هم برمیگشت: اگر کلیدی از .env سرور میافتاد،
|
||
دانشآموز با ۲۰۰ متنِ «این یک پاسخ نمونه (stub)… OPENAI_API_KEY را تنظیم
|
||
کنید» میگرفت و سهمیهی روزانهاش هم کم میشد (با تستِ دورریختنی بازتولید شد).
|
||
"""
|
||
from django.conf import settings
|
||
|
||
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
|
||
|
||
if not (settings.DEBUG or settings.RAG_FORCE_MOCK):
|
||
raise RuntimeError('RAG پیکربندی نشده (OPENAI_API_KEY/QDRANT_API_KEY)؛ stub فقط در DEBUG.')
|
||
|
||
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_quota فقط سقفِ روزانهست و جلوی burstِ کوتاهمدت رو نمیگیره؛
|
||
# این throttle_scope مجزا اون burst رو محدود میکنه.
|
||
throttle_scope = 'ai_ask'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@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
|
||
# را ثبت میکند. اگر فاز ۲/۳ شکست بخورد، رزرو فاز ۱ آزاد میشود.
|
||
# محافظِ دستیار، لایهی ۱ (rag/services/guardrails.py): ترفندِ آشکار برای عوض کردنِ
|
||
# قواعد، یا جملهای دربارهی آسیب به خود، اصلاً به مدل نمیرسد؛ پس هزینهای ندارد و از
|
||
# سهمیه و اعتبار هم کم نمیشود. سقفِ burst (throttle_scope=ai_ask) جلوی تکرارش را میگیرد.
|
||
from rag.services import guardrails
|
||
from subscription.services import credit_balance, spend_credit
|
||
|
||
screened = guardrails.screen(v['userQuestion'])
|
||
|
||
with transaction.atomic():
|
||
AccountUser.objects.select_for_update().get(pk=user.id)
|
||
|
||
# هر سوالی که به API میرسد پول است: اول از سهمیهی رایگانِ امروز، بعد از اعتبارِ بسته
|
||
# (۲۰۲۶-۱۰-۰۲). قفلِ ردیفِ کاربر و IMMEDIATEِ SQLite جلوی خرجِ دوبارهی یک اعتبار را میگیرند.
|
||
today = timezone.localdate()
|
||
used_today = Message.objects.filter(
|
||
conversation__user_id=user.id, role='user', created_at__date=today, charge='free',
|
||
).count()
|
||
quota = daily_quota(user.id)
|
||
if not screened:
|
||
_check_global_daily_cap()
|
||
if screened:
|
||
charge = 'none'
|
||
elif used_today < quota:
|
||
charge = 'free'
|
||
elif credit_balance(user.id) > 0:
|
||
charge = 'credit'
|
||
else:
|
||
from subscription.services import has_active_subscription
|
||
|
||
n = str(quota).translate(str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹'))
|
||
raise Throttled(detail=(
|
||
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد. با خریدِ بستهی سوال همین الان ادامه بده.'
|
||
if has_active_subscription(user.id) else
|
||
f'سوالهای رایگانِ امروز ({n} سوال) تمام شد. با خریدِ بستهی سوال همین الان ادامه بده.'
|
||
))
|
||
|
||
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,
|
||
charge=charge,
|
||
)
|
||
if charge == 'credit':
|
||
spend_credit(user.id, user_message.id)
|
||
# قفل ردیف کاربر اینجا آزاد شده؛ فراخوانی 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'])
|
||
|
||
age = _learner_age(user)
|
||
|
||
# --- مسیرِ استریم (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, screened=screened, quota=quota, age=age,
|
||
)
|
||
|
||
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 screened:
|
||
answer_text, follow_ups = screened[1], []
|
||
generation_meta = {'promptVersion': f'guardrail-{screened[0]}'}
|
||
elif 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'], _cache_grade(grade_title, age))
|
||
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
|
||
_uncharge(user_message)
|
||
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, age=age,
|
||
)
|
||
provider_status.record_success()
|
||
# ردِ «غیرِ ریاضی» نه منبعِ کتاب نشان میدهد نه کش میشود.
|
||
if generation_meta.get('refused'):
|
||
used_chunks = []
|
||
elif 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)
|
||
|
||
state = quota_state(user.id)
|
||
|
||
data = {
|
||
'conversationId': str(conversation.id),
|
||
'messageId': str(assistant_message.id),
|
||
'answer': answer_text,
|
||
'suggestedFollowUps': follow_ups,
|
||
'remainingQuota': state['remainingQuota'],
|
||
'credits': state['credits'],
|
||
'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, screened=None, quota=None,
|
||
age=None):
|
||
"""
|
||
نسخهی 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, guardrails
|
||
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'], _cache_grade(grade_title, age))
|
||
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 and not screened else None
|
||
if screened:
|
||
answer_text, follow_ups = screened[1], []
|
||
used_chunks, retrieval_meta = [], {}
|
||
generation_meta = {'promptVersion': f'guardrail-{screened[0]}'}
|
||
yield _sse('chunk', {'delta': answer_text})
|
||
elif 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
|
||
_uncharge(user_message)
|
||
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 ''
|
||
try:
|
||
for delta in stream_answer(
|
||
v['userQuestion'], used_chunks or None, effective_grade,
|
||
history=history, focus=focus, result=stream_result, age=age,
|
||
):
|
||
parts.append(delta)
|
||
yield _sse('chunk', {'delta': delta})
|
||
except GeneratorExit:
|
||
# کاربر صفحه را وسطِ جواب بست. سوال خرج میماند (مدل هزینهاش را گرفته)، ولی
|
||
# همان بخشی که ساخته شد ثبت میشود تا گفتگو سوالِ بیجواب نداشته باشد.
|
||
self._save_partial_answer(conversation, user, v, ''.join(parts))
|
||
raise
|
||
provider_status.record_success()
|
||
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||
follow_ups = default_follow_ups()
|
||
# توکنها از آخرین تکهی استریم میآیند (stream_options.include_usage).
|
||
# تا ۲۰۲۶-۱۰-۰۱ اینجا صفر ثبت میشد، یعنی هزینهی واقعیِ اپ هیچجا
|
||
# اندازهگیری نمیشد؛ providerی که usage نفرستد هنوز صفر میدهد.
|
||
usage = stream_result.get('usage') or {}
|
||
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'),
|
||
'promptTokens': usage.get('promptTokens', 0),
|
||
'completionTokens': usage.get('completionTokens', 0),
|
||
}
|
||
if stream_result.get('refused'):
|
||
used_chunks = []
|
||
answer_text = guardrails.REFUSAL
|
||
elif 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,
|
||
**quota_state(user.id),
|
||
'diagram': existing.assistant_message.diagram,
|
||
'usedChunks': [],
|
||
})
|
||
return
|
||
|
||
done = {
|
||
'conversationId': str(conversation.id),
|
||
'messageId': str(assistant_message.id),
|
||
'suggestedFollowUps': follow_ups,
|
||
**quota_state(user.id),
|
||
'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
|
||
|
||
@staticmethod
|
||
def _save_partial_answer(conversation, user, v, text):
|
||
logger.info('stream closed by client mid-answer: user_id=%s conversation_id=%s chars=%s',
|
||
user.id, conversation.id, len(text))
|
||
if not text.strip():
|
||
return
|
||
try:
|
||
Message.objects.create(
|
||
conversation=conversation, role='assistant', text=text.strip() + ' …',
|
||
question_id=_db_ref_or_none(v.get('questionId')),
|
||
)
|
||
except Exception:
|
||
logger.exception('saving partial answer failed: conversation_id=%s', conversation.id)
|
||
|
||
def _release_reserved_slot(self, conversation, user_message, conversation_is_new):
|
||
"""پاککردن رزرو فاز ۱ وقتی فاز بعدی شکست میخورد، تا نه پیام یتیم بماند و نه کوتا مصرفشده حساب شود."""
|
||
from subscription.services import refund_credit
|
||
|
||
with transaction.atomic():
|
||
refund_credit(user_message.id)
|
||
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
|
||
state = quota_state(existing.user_id)
|
||
|
||
data = {
|
||
'conversationId': str(conversation.id),
|
||
'messageId': str(existing.assistant_message.id),
|
||
'answer': existing.assistant_message.text,
|
||
'suggestedFollowUps': existing.suggested_follow_ups,
|
||
'remainingQuota': state['remainingQuota'],
|
||
'credits': state['credits'],
|
||
'diagram': existing.assistant_message.diagram,
|
||
}
|
||
return AskResponseSerializer(data)
|
||
|
||
|
||
class ConversationMessagesView(APIView):
|
||
"""GET /api/ai/conversations/{id}/messages — تاریخچهی مکالمه"""
|
||
|
||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای همخانواده است.
|
||
throttle_scope = 'read'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
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 — فیدبک 👍/👎"""
|
||
|
||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای همخانواده است.
|
||
throttle_scope = 'user_profile_write'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
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)
|
||
|
||
|
||
class QuotaView(APIView):
|
||
"""
|
||
GET /api/ai/quota — «امروز X سوالِ رایگان + Y سوالِ خریدهشده». اپ پیش از اولین سوال این را
|
||
نشان میدهد؛ بعد از هر جواب همین عددها در خودِ پاسخ هم میآیند (remainingQuota، credits).
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
throttle_scope = 'read'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(responses=QuotaResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
||
def get(self, request):
|
||
data = {'freeDaily': daily_quota(request.user.id), **quota_state(request.user.id)}
|
||
return Response(QuotaResponseSerializer(data).data)
|