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:
2026-10-02 02:38:43 +03:30
parent d5dbd1b23d
commit 39247f6d53
67 changed files with 1619 additions and 1140 deletions

View File

@@ -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),