senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental settings); progress.0006 drops its tables, permissions and migration history, and Profile.parent_phone, the two family notification types and ?userId= on /api/progress/nodes go with it - GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes, questions, accuracy, islands, tutor questions) for the "parent report" page - profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it - GET /api/payment/invoices; orders now snapshot list price and plan title - tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11), refusal marker replaced with fixed text; self-harm gets a help line instead - streamed answers now record token usage (it was always 0) - daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota - delete_users management command for wiping test accounts across every app - docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,11 @@ from .serializers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAILY_AI_QUOTA = 15
|
||||
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:
|
||||
@@ -77,7 +81,7 @@ class GuestAwareAIThrottle(ScopedRateThrottle):
|
||||
مشترکِ throttle bucket ai_ask رو دارن و دیگه با عوضکردنِ حساب
|
||||
نمیشه bucket تازه گرفت. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده با شماره) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی
|
||||
میمونه؛ منطقِ DAILY_AI_QUOTA و بقیهی view دستنخوردهست.
|
||||
میمونه؛ منطقِ سقفِ روزانه (daily_quota) و بقیهی view دستنخوردهست.
|
||||
"""
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
@@ -216,7 +220,7 @@ class AskAIView(APIView):
|
||||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# DAILY_AI_QUOTA فقط سقفِ روزانهست و جلوی burstِ کوتاهمدت رو نمیگیره؛
|
||||
# daily_quota فقط سقفِ روزانهست و جلوی burstِ کوتاهمدت رو نمیگیره؛
|
||||
# این throttle_scope مجزا اون burst رو محدود میکنه.
|
||||
throttle_scope = 'ai_ask'
|
||||
throttle_classes = [GuestAwareAIThrottle]
|
||||
@@ -263,8 +267,15 @@ class AskAIView(APIView):
|
||||
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='سقف روزانهی سوال از هوش مصنوعی به پایان رسیده است.')
|
||||
quota = daily_quota(user.id)
|
||||
if used_today >= quota:
|
||||
from subscription.services import has_unlimited_hearts as is_subscriber
|
||||
|
||||
raise Throttled(detail=(
|
||||
f'سقفِ امروزِ سوال از دستیار ({quota} سوال) پر شد؛ فردا دوباره میتوانی بپرسی.'
|
||||
if is_subscriber(user.id) else
|
||||
f'سوالهای رایگانِ امروز ({quota} سوال) تمام شد. با خریدِ اشتراک هر روز سوالِ بیشتری میتوانی بپرسی.'
|
||||
))
|
||||
|
||||
conversation_id = v.get('conversationId')
|
||||
conversation_is_new = not conversation_id
|
||||
@@ -351,6 +362,13 @@ class AskAIView(APIView):
|
||||
conversation.grade_id = conversation.grade_id or grade_id
|
||||
conversation.save(update_fields=['subject', 'grade_id'])
|
||||
|
||||
# محافظِ دستیار، لایهی ۱ (rag/services/guardrails.py): ترفندِ آشکار برای
|
||||
# عوض کردنِ قواعد، یا جملهای دربارهی آسیب به خود، اصلاً به مدل نمیرسد.
|
||||
# سوال در سهمیهی روز شمرده میشود تا تکرارش ارزان نباشد.
|
||||
from rag.services import guardrails
|
||||
|
||||
screened = guardrails.screen(v['userQuestion'])
|
||||
|
||||
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندیشده.
|
||||
# قراردادِ JSONِ غیراستریم دستنخورده میماند؛ Swagger که stream
|
||||
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
||||
@@ -363,7 +381,7 @@ class AskAIView(APIView):
|
||||
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,
|
||||
lesson_number=lesson_number, screened=screened, quota=quota,
|
||||
)
|
||||
|
||||
used_chunks: list[dict] = []
|
||||
@@ -374,7 +392,10 @@ class AskAIView(APIView):
|
||||
try:
|
||||
from rag.services.config import rag_is_configured
|
||||
|
||||
if 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
|
||||
@@ -408,7 +429,10 @@ class AskAIView(APIView):
|
||||
history=history, focus=focus,
|
||||
)
|
||||
provider_status.record_success()
|
||||
if cacheable:
|
||||
# ردِ «غیرِ ریاضی» نه منبعِ کتاب نشان میدهد نه کش میشود.
|
||||
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,
|
||||
@@ -455,7 +479,7 @@ class AskAIView(APIView):
|
||||
return Response(self._build_ask_response(existing).data)
|
||||
|
||||
used_today += 1 # سوال همین درخواست هم جزو مصرف امروز است
|
||||
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
|
||||
remaining_quota = max(quota - used_today, 0)
|
||||
|
||||
data = {
|
||||
'conversationId': str(conversation.id),
|
||||
@@ -484,7 +508,7 @@ class AskAIView(APIView):
|
||||
|
||||
def _streaming_response(self, *, request, v, user, conversation, user_message,
|
||||
conversation_is_new, idempotency_key, used_today,
|
||||
subject, grade_id, grade_title, lesson_number):
|
||||
subject, grade_id, grade_title, lesson_number, screened=None, quota=None):
|
||||
"""
|
||||
نسخهی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی
|
||||
پاسخ توکنبهتوکن استریم میشه. رویدادها:
|
||||
@@ -512,8 +536,13 @@ class AskAIView(APIView):
|
||||
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:
|
||||
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']
|
||||
@@ -544,17 +573,22 @@ class AskAIView(APIView):
|
||||
provider_status.record_success()
|
||||
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||||
follow_ups = default_follow_ups()
|
||||
# مسیرِ استریم usage برنمیگرداند (پاسخ توکنبهتوکن است)،
|
||||
# پس فقط چیزی را ثبت میکنیم که واقعاً میدانیم؛ توکنها صفر
|
||||
# میمانند و گزارشِ هزینه هم همین را نشان میدهد.
|
||||
# توکنها از آخرین تکهی استریم میآیند (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 cacheable:
|
||||
if stream_result.get('refused'):
|
||||
used_chunks = []
|
||||
elif cacheable:
|
||||
answer_cache.set(cache_key, {
|
||||
'answer': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
@@ -595,7 +629,7 @@ class AskAIView(APIView):
|
||||
'conversationId': str(existing.conversation_id),
|
||||
'messageId': str(existing.assistant_message_id),
|
||||
'suggestedFollowUps': existing.suggested_follow_ups,
|
||||
'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0),
|
||||
'remainingQuota': max(quota - used_today, 0),
|
||||
'diagram': existing.assistant_message.diagram,
|
||||
'usedChunks': [],
|
||||
})
|
||||
@@ -606,7 +640,7 @@ class AskAIView(APIView):
|
||||
'conversationId': str(conversation.id),
|
||||
'messageId': str(assistant_message.id),
|
||||
'suggestedFollowUps': follow_ups,
|
||||
'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0),
|
||||
'remainingQuota': max(quota - used_today, 0),
|
||||
'diagram': assistant_message.diagram,
|
||||
'usedChunks': [
|
||||
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||||
@@ -662,7 +696,7 @@ class AskAIView(APIView):
|
||||
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)
|
||||
remaining_quota = max(daily_quota(existing.user_id) - used_today, 0)
|
||||
|
||||
data = {
|
||||
'conversationId': str(conversation.id),
|
||||
|
||||
Reference in New Issue
Block a user