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:
65
ai_tutor/provider_status.py
Normal file
65
ai_tutor/provider_status.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
وضعیتِ provider هوش مصنوعی (GapGPT): «کار میکند» یا «اعتبار تمام شده».
|
||||
|
||||
چرا لازم است: ۲۰۲۶-۰۹-۲۵ اعتبارِ GapGPT تمام شد و دستیار برای همهی کاربرها
|
||||
۵۰۳ داد («دستیار الان در دسترس نیست»)، در حالی که /api/health همچنان
|
||||
`openai: true` نشان میداد — چون فقط «کلید تنظیم شده» را میدید. تنها راهِ
|
||||
فهمیدنش خواندنِ لاگِ سرور بود.
|
||||
|
||||
حالا هر گذار (ok → quota_exhausted و برعکس) یک ردیف در statuslog میشود:
|
||||
همهی workerها آن را میبینند (کشِ LocMem اینطور نیست) و /api/health
|
||||
گزارشش میکند. فقط گذار ثبت میشود، نه هر درخواست.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LABEL = 'ai_tutor.Provider'
|
||||
OK = 'ok'
|
||||
EXHAUSTED = 'quota_exhausted'
|
||||
_CACHE_KEY = 'ai_tutor:provider_state'
|
||||
|
||||
|
||||
def current_state() -> str:
|
||||
state = cache.get(_CACHE_KEY)
|
||||
if state is None:
|
||||
from statuslog.models import StatusChangeLog
|
||||
|
||||
state = (
|
||||
StatusChangeLog.objects.filter(label=LABEL)
|
||||
.values_list('new_status', flat=True).first()
|
||||
) or OK
|
||||
cache.set(_CACHE_KEY, state, 60)
|
||||
return state
|
||||
|
||||
|
||||
def _transition(new_state: str, event: str) -> None:
|
||||
from statuslog.signals import status_changed
|
||||
|
||||
old_state = current_state()
|
||||
if old_state == new_state:
|
||||
return
|
||||
status_changed.send(sender=LABEL, object_id=0, old_status=old_state,
|
||||
new_status=new_state, event=event)
|
||||
cache.set(_CACHE_KEY, new_state, 60)
|
||||
|
||||
|
||||
def record_failure(exc) -> None:
|
||||
"""خطای provider؛ فقط تمامشدنِ اعتبار/سقفِ حساب وضعیت را عوض میکند."""
|
||||
from rag.services.clients import is_account_limit
|
||||
|
||||
try:
|
||||
if is_account_limit(exc):
|
||||
_transition(EXHAUSTED, 'provider_account_limit')
|
||||
except Exception: # noqa: BLE001 — گزارشدهی نباید پاسخِ ۵۰۳ را خراب کند
|
||||
logger.exception('provider_status: ثبتِ خطا شکست خورد')
|
||||
|
||||
|
||||
def record_success() -> None:
|
||||
try:
|
||||
if current_state() != OK:
|
||||
_transition(OK, 'provider_recovered')
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception('provider_status: ثبتِ بازگشت شکست خورد')
|
||||
@@ -18,7 +18,9 @@ class AskRequestSerializer(serializers.Serializer):
|
||||
# RAG_GRADE_TITLE_OVERRIDES ساخته میشه.
|
||||
grade = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
lessonNumber = serializers.IntegerField(required=False, allow_null=True)
|
||||
userQuestion = serializers.CharField()
|
||||
# سقفِ طول: کلِ متن به مدلِ پولی میرود؛ بیسقف، یک درخواستِ صدهزار نویسهای
|
||||
# بهتنهایی هزینهی صدها سوالِ عادی را داشت.
|
||||
userQuestion = serializers.CharField(max_length=2000)
|
||||
stream = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
|
||||
@@ -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