public user IDs, plans priced by tutor cost, age-based tutor tone, self-service account deletion

- profile publicId ("User482917", accounts/public_id.py); admin shows and searches it
- plans API reports aiDailyQuota per plan and freeAiDailyQuota; migration 0007 sets the
  temporary prices (monthly 99,000 / yearly 990,000 toman, 5 tutor questions a day) unless
  an admin already edited the yearly plan
- tutor tone follows the learner's age (18+ gets an adult tone at any grade); notation
  still follows the grade's book; answer cache keyed by tone (prompt tutor-v12-age-tone)
- POST /api/user/delete: phone users confirm with a fresh SMS code (same checks as login,
  now shared in verify_phone_otp), guests type "حذف"; payment records are kept, demo
  accounts refused
- delete_users --skip-paid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 06:10:17 +03:30
parent e9d676ce68
commit 63cd1af6f7
18 changed files with 468 additions and 78 deletions

View File

@@ -28,6 +28,22 @@ from .serializers import (
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 daily_quota(user_id: int) -> int:
"""سقفِ سوالِ روزانه‌ی این کاربر؛ رایگان یا از پلنِ اشتراکش (subscription.services)."""
from subscription.services import ai_daily_quota
@@ -368,6 +384,7 @@ class AskAIView(APIView):
from rag.services import guardrails
screened = guardrails.screen(v['userQuestion'])
age = _learner_age(user)
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندی‌شده.
# قراردادِ JSONِ غیراستریم دست‌نخورده می‌ماند؛ Swagger که stream
@@ -381,7 +398,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, screened=screened, quota=quota,
lesson_number=lesson_number, screened=screened, quota=quota, age=age,
)
used_chunks: list[dict] = []
@@ -404,7 +421,7 @@ class AskAIView(APIView):
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)
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']
@@ -426,7 +443,7 @@ class AskAIView(APIView):
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,
history=history, focus=focus, age=age,
)
provider_status.record_success()
# ردِ «غیرِ ریاضی» نه منبعِ کتاب نشان می‌دهد نه کش می‌شود.
@@ -508,7 +525,8 @@ 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, screened=None, quota=None):
subject, grade_id, grade_title, lesson_number, screened=None, quota=None,
age=None):
"""
نسخه‌ی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی
پاسخ توکن‌به‌توکن استریم می‌شه. رویدادها:
@@ -531,7 +549,7 @@ class AskAIView(APIView):
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)
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)
@@ -566,7 +584,7 @@ class AskAIView(APIView):
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, result=stream_result,
history=history, focus=focus, result=stream_result, age=age,
):
parts.append(delta)
yield _sse('chunk', {'delta': delta})