tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed

Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 06:24:13 +03:30
parent fbdb9495ee
commit 42783f9ba4
56 changed files with 2792 additions and 326 deletions

View File

@@ -16,6 +16,7 @@ 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 (
AskRequestSerializer,
@@ -103,6 +104,43 @@ def _is_db_ref_id(value) -> bool:
return str(value).strip().lstrip('-').isdigit()
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 AIProviderUnavailable(APIException):
"""
⚠️ افزوده شده: وقتی generate_tutor_answer به یک AI provider واقعی
@@ -330,9 +368,12 @@ class AskAIView(APIView):
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'], grade_title)
cached = answer_cache.get(cache_key)
cached = answer_cache.get(cache_key) if cacheable else None
if cached is not None:
answer_text = cached['answer']
follow_ups = list(cached['followUps'])
@@ -342,7 +383,8 @@ class AskAIView(APIView):
answer_from_cache = True
else:
used_chunks, retrieval_meta = retrieve_context(
v['userQuestion'], subject=subject, grade_id=grade_id,
_retrieval_query(v['userQuestion'], history, focus),
subject=subject, grade_id=grade_id,
grade_title=grade_title, lesson_number=lesson_number,
)
used_chunks = [
@@ -352,18 +394,22 @@ 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,
)
answer_cache.set(cache_key, {
'answer': answer_text, 'followUps': follow_ups,
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
'generationMeta': generation_meta,
})
provider_status.record_success()
if 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:
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()
@@ -436,7 +482,9 @@ class AskAIView(APIView):
فاز ۱ (رزرو کوتا + 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.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:
@@ -449,7 +497,10 @@ class AskAIView(APIView):
_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)
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:
answer_text = cached['answer']
follow_ups = list(cached['followUps'])
@@ -460,7 +511,8 @@ class AskAIView(APIView):
yield _sse('chunk', {'delta': answer_text})
else:
used_chunks, retrieval_meta = retrieve_context(
v['userQuestion'], subject=subject, grade_id=grade_id,
_retrieval_query(v['userQuestion'], history, focus),
subject=subject, grade_id=grade_id,
grade_title=grade_title, lesson_number=lesson_number,
)
used_chunks = [
@@ -469,30 +521,34 @@ class AskAIView(APIView):
]
parts: list[str] = []
started = time.monotonic()
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
for delta in stream_answer(
v['userQuestion'], used_chunks or None,
retrieval_meta.get('gradeTitle') or grade_title or '',
v['userQuestion'], used_chunks or None, effective_grade,
history=history, focus=focus,
):
parts.append(delta)
yield _sse('chunk', {'delta': delta})
provider_status.record_success()
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
follow_ups = default_follow_ups()
# مسیرِ استریم usage برنمی‌گرداند (پاسخ توکن‌به‌توکن است)،
# پس فقط چیزی را ثبت می‌کنیم که واقعاً می‌دانیم؛ توکن‌ها صفر
# می‌مانند و گزارشِ هزینه هم همین را نشان می‌دهد.
generation_meta = {
'model': settings.OPENAI_CHAT_MODEL,
'model': chat_model_for(effective_grade),
'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:
if 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