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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user