make the tutor grade-aware and its answers measurable

مهم‌ترین خواسته‌ی سینیور این بود که بسنجیم پاسخِ AI واقعاً یاد می‌دهد یا نه.
برای سنجیدن، اول باید پاسخ‌ها قابلِ ردیابی می‌شدند.

پرامپت‌ها پایه‌آگاه شدند: تا پیش از این «معلمِ دبستانی» برای پایه نهم هاردکد
بود، هم در تولیدِ پاسخ و هم در vision — یعنی دانش‌آموزِ نهم لحنِ ابتدایی
می‌گرفت. PROMPT_VERSION حالا روی هر پیام ذخیره می‌شود.

هر پاسخ حاشیه‌نویسی می‌شود: مدل، نسخه‌ی پرامپت، پایه، collection، مقدارِ
فیلتر، تعداد chunk، توکن، تأخیر و from_cache. فیدبک reason اختیاری گرفت تا
لایک/دیسلایک بعداً قابلِ تحلیل باشد («بشه چک کرد کدوم بهتر بوده»).

ai_feedback_report --by prompt_version نسبتِ رضایت را کنارِ مصرف/هزینه
می‌گذارد. rag_eval سوال‌های واقعی را از مسیرِ واقعی می‌پرسد، با
--retrieval-only برای اجرای ارزان.

rag/services/lesson_context.py از lessonId تنها پایه و فصل را درمی‌آورد؛
normalize_grade_title هم اختلافِ «پایه نهم» در دیتابیس با «ریاضی نهم» در
نامِ collection را می‌بندد — بدونِ آن مسیرِ gradeId بی‌سروصدا خالی برمی‌گشت.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 19:00:40 +03:30
parent 78130e7194
commit fa56308a35
11 changed files with 731 additions and 32 deletions

View File

@@ -1,5 +1,6 @@
import json
import logging
import time
from django.conf import settings
from django.db import IntegrityError, transaction
@@ -29,6 +30,32 @@ logger = logging.getLogger(__name__)
DAILY_AI_QUOTA = 15
def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool) -> dict:
"""
متادیتای بازیابی/تولید → فیلدهای Message.
همه‌ی مقادیر به str/int امن تبدیل می‌شوند و هر کلیدِ غایب مقدارِ پیش‌فرض
می‌گیرد: این متادیتا از cache هم می‌آید و cacheهای قدیمی (که پیش از افزودنِ
این فیلدها ست شده‌اند) کلیدها را ندارند — نبودشان نباید ثبتِ پیام را
بشکند، چون آن‌وقت خودِ پاسخِ کاربر هم از بین می‌رود.
"""
retrieval_meta = retrieval_meta or {}
generation_meta = generation_meta or {}
filter_value = retrieval_meta.get('filterValue')
return {
'model_name': str(generation_meta.get('model') or '')[:100],
'prompt_version': str(generation_meta.get('promptVersion') or '')[:50],
'grade_title': str(retrieval_meta.get('gradeTitle') or '')[:50],
'retrieval_collection': str(retrieval_meta.get('collection') or '')[:200],
'retrieval_filter_value': ('' if filter_value is None else str(filter_value))[:255],
'chunk_count': int(retrieval_meta.get('chunkCount') or 0),
'prompt_tokens': int(generation_meta.get('promptTokens') or 0),
'completion_tokens': int(generation_meta.get('completionTokens') or 0),
'latency_ms': int(generation_meta.get('latencyMs') or 0),
'from_cache': bool(from_cache),
}
class GuestAwareAIThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: ScopedRateThrottle استاندارد برای کاربرِ احرازهویت‌شده
@@ -121,7 +148,10 @@ def generate_tutor_answer(
context_chunks = context if isinstance(context, list) else None
# استثناهای شبکه/provider اینجا خفه نمی‌شن؛ AskAIView اون‌ها رو به
# 503 AIProviderUnavailable تبدیل می‌کنه (و اسلاتِ رزروشده رو آزاد).
return generate_answer(user_question, context_chunks)
# این تابع قراردادِ دوتایی‌اش را نگه می‌دارد؛ متادیتای تولید فقط در
# مسیرِ اصلیِ AskAIView (که آن را ذخیره می‌کند) لازم است.
answer, follow_ups, _meta = generate_answer(user_question, context_chunks)
return answer, follow_ups
context_note = f' (با context: {context})' if context else ''
answer = (
@@ -223,14 +253,40 @@ class AskAIView(APIView):
# اگر RAG پیکربندی نشده باشد context=None می‌ماند و generate_tutor_answer
# همان پاسخِ stub را می‌دهد. خطای شبکه/provider در همین try به 503
# تبدیل و اسلاتِ رزروشده آزاد می‌شود.
grade_id = v.get('gradeId')
lesson_id = v.get('lessonId')
# مقادیرِ ذخیره‌شده روی مکالمه هم fallback‌اند: در پیامِ دوم به بعد، اپ
# فقط conversationId می‌فرستد و دیگر lessonId/gradeId نمی‌آید.
grade_id = v.get('gradeId') or conversation.grade_id
lesson_id = v.get('lessonId') or conversation.lesson_id
subject = (v.get('subject') or conversation.subject or '').strip()
grade_title = (v.get('grade') or '').strip()
lesson_number = v.get('lessonNumber')
if lesson_number is None:
lesson_number = conversation.lesson_number
# اگر اپ فقط lessonId (یا فقط conversationId) داده — حالتِ طبیعی، چون
# می‌داند دانش‌آموز روی کدام درس است نه اینکه نامِ collectionِ Qdrant
# چیست — خودمان subject/پایه/فصل را از زنجیره‌ی
# Lesson→Node→Unit→Grade درمی‌آوریم.
#
# ⚠️ شرط عمداً `grade_title` را هم می‌بیند: پیش‌تر فقط subject و
# lesson_number را چک می‌کرد، و چون Conversation فیلدی برای عنوانِ پایه
# ندارد، از پیامِ دومِ هر مکالمه grade_title خالی می‌ماند و
# resolve_target هیچ collectionی نمی‌ساخت — یعنی فقط جوابِ اول به کتاب
# مستند بود و بقیه بی‌سروصدا عمومی می‌شدند.
if lesson_id and not (subject and grade_title and lesson_number is not None):
from rag.services.lesson_context import resolve_from_lesson
derived = resolve_from_lesson(lesson_id)
subject = subject or derived.get('subject', '')
grade_title = grade_title or derived.get('gradeTitle', '')
if lesson_number is None:
lesson_number = derived.get('chapter')
# تا آخرِ همین مکالمه دوباره حساب نشود.
if subject and not conversation.subject:
conversation.subject = subject
conversation.lesson_number = lesson_number
conversation.save(update_fields=['subject', 'lesson_number'])
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندی‌شده.
# قراردادِ JSONِ غیراستریم دست‌نخورده می‌ماند؛ Swagger که stream
# نمی‌فرسته همون مسیرِ قبلی رو می‌گیره.
@@ -248,6 +304,8 @@ class AskAIView(APIView):
used_chunks: list[dict] = []
retrieval_meta: dict = {}
generation_meta: dict = {}
answer_from_cache = False
context = None
try:
from rag.services.config import rag_is_configured
@@ -258,13 +316,15 @@ class AskAIView(APIView):
from rag.services.retrieval import resolve_target, retrieve_context
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'])
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
cached = answer_cache.get(cache_key)
if cached is not None:
answer_text = cached['answer']
follow_ups = list(cached['followUps'])
used_chunks = cached['usedChunks']
retrieval_meta = cached.get('retrievalMeta') or {}
generation_meta = cached.get('generationMeta') or {}
answer_from_cache = True
else:
used_chunks, retrieval_meta = retrieve_context(
v['userQuestion'], subject=subject, grade_id=grade_id,
@@ -274,10 +334,14 @@ class AskAIView(APIView):
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
for c in used_chunks
]
answer_text, follow_ups = generate_answer(v['userQuestion'], used_chunks or None)
answer_text, follow_ups, generation_meta = generate_answer(
v['userQuestion'], used_chunks or None,
retrieval_meta.get('gradeTitle') or grade_title or '',
)
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(
@@ -295,6 +359,7 @@ class AskAIView(APIView):
role='assistant',
text=answer_text,
question_id=v.get('questionId') or None,
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
)
if idempotency_key:
@@ -356,7 +421,7 @@ class AskAIView(APIView):
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
"""
from rag.services import answer_cache
from rag.services.generation import default_follow_ups, stream_answer
from rag.services.generation import PROMPT_VERSION, default_follow_ups, stream_answer
from rag.services.retrieval import resolve_target, retrieve_context
def _sse(event: str, payload: dict) -> str:
@@ -364,8 +429,10 @@ class AskAIView(APIView):
def _gen():
nonlocal used_today
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'])
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
try:
cached = answer_cache.get(cache_key)
if cached is not None:
@@ -373,6 +440,8 @@ class AskAIView(APIView):
follow_ups = list(cached['followUps'])
used_chunks = cached['usedChunks']
retrieval_meta = cached.get('retrievalMeta') or {}
generation_meta = cached.get('generationMeta') or {}
answer_from_cache = True
yield _sse('chunk', {'delta': answer_text})
else:
used_chunks, retrieval_meta = retrieve_context(
@@ -384,14 +453,28 @@ class AskAIView(APIView):
for c in used_chunks
]
parts: list[str] = []
for delta in stream_answer(v['userQuestion'], used_chunks or None):
started = time.monotonic()
for delta in stream_answer(
v['userQuestion'], used_chunks or None,
retrieval_meta.get('gradeTitle') or grade_title or '',
):
parts.append(delta)
yield _sse('chunk', {'delta': delta})
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
follow_ups = default_follow_ups()
# مسیرِ استریم usage برنمی‌گرداند (پاسخ توکن‌به‌توکن است)،
# پس فقط چیزی را ثبت می‌کنیم که واقعاً می‌دانیم؛ توکن‌ها صفر
# می‌مانند و گزارشِ هزینه هم همین را نشان می‌دهد.
generation_meta = {
'model': settings.OPENAI_CHAT_MODEL,
'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:
logger.exception('stream generate failed for user_id=%s', user.id)
@@ -405,6 +488,7 @@ class AskAIView(APIView):
assistant_message = Message.objects.create(
conversation=conversation, role='assistant', text=answer_text,
question_id=v.get('questionId') or None,
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
)
if idempotency_key:
AskIdempotency.objects.create(
@@ -540,8 +624,11 @@ class MessageFeedbackView(APIView):
serializer = FeedbackRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
MessageFeedback.objects.update_or_create(
message=message,
defaults={'is_helpful': serializer.validated_data['isHelpful']},
)
# `reason` فقط وقتی نوشته می‌شود که در همین درخواست آمده باشد: کاربری
# که رأیش را عوض می‌کند معمولاً دلیل نمی‌نویسد، و پیش‌فرضِ '' متنِ
# قبلی‌اش را پاک می‌کرد — یعنی مفیدترین بخشِ فیدبک از دست می‌رفت.
defaults = {'is_helpful': serializer.validated_data['isHelpful']}
if 'reason' in serializer.validated_data:
defaults['reason'] = serializer.validated_data['reason']
MessageFeedback.objects.update_or_create(message=message, defaults=defaults)
return Response(FeedbackResponseSerializer({'success': True}).data)