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

View File

View File

@@ -0,0 +1,135 @@
"""
گزارشِ کیفیت و مصرفِ دستیارِ هوش مصنوعی.
سینیور دو چیز خواست و هر دو از همین یک جدول درمی‌آیند:
«یک ساینتیشن برای لایک/دیسلایک بگیریم که بعداً بشود چک کرد کدام بهتر بوده»
«اگر لیمیت خورد یا تمام شد بهم بگید که شارژش کنیم»
نمونه:
python manage.py ai_feedback_report
python manage.py ai_feedback_report --days 7 --by grade_title
"""
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Avg, Count, Q, Sum
from django.utils import timezone
from ai_tutor.models import Message
# قیمتِ تقریبیِ هر یک میلیون توکن (دلار) برای برآوردِ هزینه. عدد دقیق نیست و
# فقط برای «چقدر مانده / کِی باید شارژ کنیم» است.
_PRICE_PER_MTOK = {
'gpt-4o-mini': (0.15, 0.60),
'gpt-4o': (2.50, 10.00),
}
_GROUPABLE = ['prompt_version', 'model_name', 'grade_title', 'retrieval_collection']
class Command(BaseCommand):
help = 'نسبتِ 👍/👎 و مصرفِ توکنِ پاسخ‌های دستیار، به تفکیکِ نسخه‌ی پرامپت/مدل/پایه.'
def add_arguments(self, parser):
parser.add_argument('--days', type=int, default=30)
parser.add_argument('--by', choices=_GROUPABLE, default='prompt_version')
def handle(self, *args, **opts):
since = timezone.now() - timedelta(days=opts['days'])
field = opts['by']
qs = Message.objects.filter(role='assistant', created_at__gte=since)
total = qs.count()
if not total:
self.stdout.write(self.style.WARNING(
f'در {opts["days"]} روزِ گذشته هیچ پاسخِ دستیاری ثبت نشده است.'
))
return
rows = (
qs.values(field)
.annotate(
answers=Count('id'),
liked=Count('id', filter=Q(feedback__is_helpful=True)),
disliked=Count('id', filter=Q(feedback__is_helpful=False)),
with_context=Count('id', filter=Q(chunk_count__gt=0)),
avg_latency=Avg('latency_ms'),
tok_in=Sum('prompt_tokens'),
tok_out=Sum('completion_tokens'),
)
.order_by('-answers')
)
self.stdout.write(self.style.MIGRATE_HEADING(
f'\n{opts["days"]} روزِ گذشته — {total} پاسخِ دستیار، گروه‌بندی بر اساسِ {field}\n'
))
header = f'{field:<28} {"پاسخ":>6} {"👍":>5} {"👎":>5} {"رضایت":>8} {"با منبع":>9} {"میانگین ms":>11}'
self.stdout.write(header)
self.stdout.write('─' * len(header))
for r in rows:
rated = r['liked'] + r['disliked']
ratio = f"{100 * r['liked'] / rated:.0f}%" if rated else '—'
ctx = f"{100 * r['with_context'] / r['answers']:.0f}%"
label = (r[field] or '(خالی)')[:27]
self.stdout.write(
f"{label:<28} {r['answers']:>6} {r['liked']:>5} {r['disliked']:>5} "
f"{ratio:>8} {ctx:>9} {(r['avg_latency'] or 0):>11.0f}"
)
rated_total = qs.filter(feedback__isnull=False).count()
self.stdout.write('')
self.stdout.write(
f'رأی داده‌شده: {rated_total} از {total} پاسخ '
f'({100 * rated_total / total:.0f}%).'
)
# --- مصرف و هزینه ---------------------------------------------------
self.stdout.write(self.style.MIGRATE_HEADING('\nمصرفِ توکن (برای پیگیریِ شارژ)\n'))
# ⚠️ پاسخِ کش‌شده توکنِ فراخوانیِ اصلی را کپی می‌کند و هیچ هزینه‌ای
# نداشته؛ اگر بشماریمش، گزارشی که کارش تصمیم‌گیری درباره‌ی شارژ است
# به اندازه‌ی نرخِ اصابتِ کش هزینه را بیشتر نشان می‌دهد.
usage = (
qs.exclude(model_name='').filter(from_cache=False)
.values('model_name')
.annotate(tok_in=Sum('prompt_tokens'), tok_out=Sum('completion_tokens'), n=Count('id'))
.order_by('-n')
)
grand = 0.0
for u in usage:
tin, tout = u['tok_in'] or 0, u['tok_out'] or 0
price = _PRICE_PER_MTOK.get(u['model_name'])
if price:
cost = tin / 1e6 * price[0] + tout / 1e6 * price[1]
grand += cost
cost_text = f'≈ ${cost:.2f}'
else:
cost_text = 'قیمتِ این مدل در جدول نیست'
self.stdout.write(
f" {u['model_name']:<20} {u['n']:>5} پاسخ ورودی {tin:>9,} خروجی {tout:>9,} {cost_text}"
)
if grand:
self.stdout.write(self.style.WARNING(f'\n جمعِ تقریبی: ${grand:.2f}'))
cached = qs.filter(from_cache=True).count()
self.stdout.write(self.style.HTTP_INFO(
' توجه: پاسخ‌های استریمی usage برنمی‌گردانند و توکنشان صفر شمرده می‌شود، '
'پس عددِ واقعی از این بیشتر است.'
))
if cached:
self.stdout.write(self.style.HTTP_INFO(
f' {cached} پاسخ از کش آمده و در هزینه شمرده نشده (فراخوانیِ تازه‌ای نداشته).'
))
# --- بدترین پاسخ‌ها ---------------------------------------------------
worst = (
qs.filter(feedback__is_helpful=False)
.select_related('feedback')
.order_by('-created_at')[:10]
)
if worst:
self.stdout.write(self.style.MIGRATE_HEADING('\nآخرین پاسخ‌های 👎 (برای بازبینیِ دستی)\n'))
for m in worst:
reason = (m.feedback.reason or '').strip()
self.stdout.write(
f" #{m.id} [{m.prompt_version or '—'} / {m.grade_title or '—'} / "
f"chunk={m.chunk_count}] {m.text[:70]}…"
+ (f'\n دلیلِ کاربر: {reason}' if reason else '')
)

View File

@@ -0,0 +1,78 @@
# Generated by Django 5.2.17 on 2026-09-12 14:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_tutor', '0004_conversation_lesson_number_conversation_subject'),
]
operations = [
migrations.AddField(
model_name='message',
name='chunk_count',
field=models.PositiveSmallIntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='completion_tokens',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='from_cache',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='message',
name='grade_title',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='message',
name='latency_ms',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='model_name',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='message',
name='prompt_tokens',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='prompt_version',
field=models.CharField(blank=True, db_index=True, max_length=50),
),
migrations.AddField(
model_name='message',
name='retrieval_collection',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='message',
name='retrieval_filter_value',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='messagefeedback',
name='created_at',
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='messagefeedback',
name='reason',
field=models.CharField(blank=True, max_length=300),
),
migrations.AddField(
model_name='messagefeedback',
name='updated_at',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -27,6 +27,23 @@ class Message(models.Model):
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
# --- حاشیه‌نویسیِ تولید (فقط برای پیام‌های role='assistant') --------------
# سینیور خواست لایک/دیسلایک بعداً قابلِ تحلیل باشد: «کدام جواب بهتر بوده».
# فیدبکِ خالی (یک بولین) این را نمی‌گوید — باید بدانیم آن جوابِ خاص با کدام
# مدل، کدام نسخه‌ی پرامپت و با چه context ای ساخته شده بود. این فیلدها
# هنگامِ تولید پر می‌شوند، نه هنگامِ فیدبک، تا همه‌ی پاسخ‌ها (حتی آن‌هایی که
# کسی رأی نداده) قابلِ مقایسه بمانند.
model_name = models.CharField(max_length=100, blank=True)
prompt_version = models.CharField(max_length=50, blank=True, db_index=True)
grade_title = models.CharField(max_length=50, blank=True)
retrieval_collection = models.CharField(max_length=200, blank=True)
retrieval_filter_value = models.CharField(max_length=255, blank=True)
chunk_count = models.PositiveSmallIntegerField(default=0)
prompt_tokens = models.PositiveIntegerField(default=0)
completion_tokens = models.PositiveIntegerField(default=0)
latency_ms = models.PositiveIntegerField(default=0)
from_cache = models.BooleanField(default=False)
class Meta: class Meta:
ordering = ['created_at'] ordering = ['created_at']
@@ -37,6 +54,11 @@ class Message(models.Model):
class MessageFeedback(models.Model): class MessageFeedback(models.Model):
message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback') message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback')
is_helpful = models.BooleanField() is_helpful = models.BooleanField()
# دلیلِ اختیاریِ کاربر. «چرا بد بود» تنها چیزی‌ست که از خودِ عدد درنمی‌آید
# و برای بهترکردنِ پرامپت واقعاً به کار می‌آید.
reason = models.CharField(max_length=300, blank=True)
created_at = models.DateTimeField(auto_now_add=True, null=True)
updated_at = models.DateTimeField(auto_now=True, null=True)
def __str__(self): def __str__(self):
return f"feedback-msg{self.message_id}" return f"feedback-msg{self.message_id}"

View File

@@ -54,6 +54,9 @@ class ConversationMessagesResponseSerializer(serializers.Serializer):
class FeedbackRequestSerializer(serializers.Serializer): class FeedbackRequestSerializer(serializers.Serializer):
isHelpful = serializers.BooleanField() isHelpful = serializers.BooleanField()
# اختیاری: «چرا». تنها چیزی که از خودِ 👍/👎 درنمی‌آید و برای اصلاحِ
# پرامپت واقعاً به کار می‌آید.
reason = serializers.CharField(max_length=300, required=False, allow_blank=True)
class FeedbackResponseSerializer(serializers.Serializer): class FeedbackResponseSerializer(serializers.Serializer):

View File

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

View File

@@ -0,0 +1,218 @@
"""
سنجشِ کیفیتِ پاسخ‌های دستیار روی دادهٔ واقعی.
سینیور: «مهم‌ترین بخش پروژه اینه که ببینیم اون چیزی که AI می‌ده بدردخوره،
چیزیه که می‌شه ازش یاد گرفت.» این فرمان همان را قابلِ دیدن می‌کند: مجموعه‌ای
سوال را از مسیرِ واقعیِ retrieval + generation می‌گذراند و پرسش، تکه‌های
بازیابی‌شده (با امتیازشان) و پاسخ را کنارِ هم چاپ می‌کند تا آدم قضاوت کند.
# فقط بازیابی — هزینه‌اش تنها embedding است (کسری از سنت)
python manage.py rag_eval --grade نهم --retrieval-only
# کامل، با تولیدِ پاسخ
python manage.py rag_eval --grade نهم --chapters 1 4
# سوال‌های خودتان
python manage.py rag_eval --questions data/eval/questions.json --json out.json
"""
import json
import time
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from rag.services.config import rag_is_configured
from rag.services.generation import generate_answer
from rag.services.retrieval import retrieve_context
from rag.services.textbooks import TEXTBOOKS, get_textbook
# سوال‌های پیش‌فرض به تفکیکِ (پایه، شماره‌ی فصل). عمداً از جنسِ همان‌جایی‌اند
# که بچه‌ها گیر می‌کنند، نه سوالِ تعریفیِ ساده.
DEFAULT_QUESTIONS = {
('نهم', 1): [
'فرق مجموعه‌ی تهی با مجموعه‌ای که فقط عضو صفر دارد چیست؟',
'اجتماع و اشتراک دو مجموعه چه فرقی دارند؟ با یک مثال توضیح بده.',
],
('نهم', 2): [
'چطور بفهمم یک عدد گویا است یا گنگ؟',
'قدر مطلق یعنی چه و چرا همیشه مثبت است؟',
],
('نهم', 4): [
'چرا هر عددی به توان صفر می‌شود یک؟',
'چطور دو رادیکال را با هم جمع کنم؟',
],
('نهم', 5): [
'اتحاد مربع دوجمله‌ای را با یک مثال توضیح بده.',
'تجزیه یعنی چه و به چه دردی می‌خورد؟',
],
('دهم', 2): [
'سینوس و کسینوس یک زاویه یعنی چه؟',
'دایره‌ی مثلثاتی چه کمکی به ما می‌کند؟',
],
('دهم', 4): [
'معادله‌ی درجه دوم را چطور با تشکیل مربع کامل حل کنم؟',
'دلتا چه چیزی را به ما می‌گوید؟',
],
('دهم', 5): [
'فرق دامنه و برد یک تابع چیست؟',
'از کجا بفهمم یک رابطه تابع است یا نه؟',
],
}
class Command(BaseCommand):
help = 'اجرای مجموعه‌ای سوالِ واقعی روی مسیرِ RAG و چاپِ پاسخ‌ها برای بازبینیِ انسانی.'
def add_arguments(self, parser):
parser.add_argument('--subject', default='ریاضی')
parser.add_argument('--grade', help='مثلاً نهم؛ خالی یعنی همه‌ی پایه‌های ثبت‌شده.')
parser.add_argument('--chapters', nargs='*', type=int, default=None)
parser.add_argument('--questions', help='فایلِ JSON: [{"grade","chapter","question"}, ...]')
parser.add_argument('--retrieval-only', action='store_true',
help='پاسخ تولید نکن؛ فقط تکه‌های بازیابی‌شده را نشان بده (ارزان).')
parser.add_argument('--json', dest='json_out', help='ذخیره‌ی نتیجه در این فایل.')
parser.add_argument('--top-k', type=int, default=4)
def handle(self, *args, **opts):
if not rag_is_configured():
raise CommandError('RAG پیکربندی نشده (کلیدها خالی‌اند یا RAG_FORCE_MOCK روشن است).')
cases = self._load_cases(opts)
if not cases:
raise CommandError('هیچ سوالی برای اجرا پیدا نشد.')
self.stdout.write(self.style.MIGRATE_HEADING(
f'\n{len(cases)} سوال — '
+ ('فقط بازیابی' if opts['retrieval_only'] else 'بازیابی + تولیدِ پاسخ')
+ '\n'
))
results = []
empty_retrievals = 0
for index, case in enumerate(cases, start=1):
row = self._run_case(index, case, opts)
results.append(row)
if not row['chunks']:
empty_retrievals += 1
self._summary(results, empty_retrievals, opts)
if opts['json_out']:
Path(opts['json_out']).write_text(
json.dumps(results, ensure_ascii=False, indent=2), encoding='utf-8',
)
self.stdout.write(f"\nنتیجه در {opts['json_out']} ذخیره شد.")
# ------------------------------------------------------------------
def _load_cases(self, opts):
if opts['questions']:
path = Path(opts['questions'])
if not path.exists():
raise CommandError(f'فایل پیدا نشد: {path}')
raw = json.loads(path.read_text(encoding='utf-8'))
return [
{'grade': r['grade'], 'chapter': int(r['chapter']), 'question': r['question']}
for r in raw
]
grades = [opts['grade']] if opts['grade'] else sorted(
{b.grade for b in TEXTBOOKS.values()}
)
cases = []
for grade in grades:
if opts['grade'] and get_textbook(opts['subject'], grade) is None:
raise CommandError(f'کتابی برای «{opts["subject"]} {grade}» ثبت نشده است.')
for (g, chapter), questions in DEFAULT_QUESTIONS.items():
if g != grade:
continue
if opts['chapters'] and chapter not in opts['chapters']:
continue
cases.extend(
{'grade': grade, 'chapter': chapter, 'question': q} for q in questions
)
return cases
def _run_case(self, index, case, opts):
subject = opts['subject']
started = time.monotonic()
chunks, meta = retrieve_context(
case['question'], subject=subject, grade_title=case['grade'],
lesson_number=case['chapter'], limit=opts['top_k'],
)
retrieval_ms = int((time.monotonic() - started) * 1000)
self.stdout.write(self.style.HTTP_INFO(
f"\n[{index}] {case['grade']} / فصل {case['chapter']} — {case['question']}"
))
self.stdout.write(
f" collection={meta.get('collection')} filter={meta.get('filterValue')} "
f"chunk={len(chunks)} ({retrieval_ms} ms)"
)
if not chunks:
self.stdout.write(self.style.ERROR(
' ⚠ هیچ تکه‌ای پیدا نشد — یعنی مدل بدونِ پشتوانه‌ی کتاب جواب می‌دهد.'
))
for chunk in chunks:
self.stdout.write(
f" • [{chunk.get('score', 0):.3f}] {chunk.get('title', '')[:40]}"
f" — {chunk.get('summary', '')[:90].replace(chr(10), ' ')}…"
)
row = {
**case,
'collection': meta.get('collection'),
'filterValue': meta.get('filterValue'),
'chunks': [
{'title': c.get('title'), 'score': c.get('score'), 'summary': c.get('summary')}
for c in chunks
],
'retrievalMs': retrieval_ms,
}
if opts['retrieval_only']:
return row
answer, _follow_ups, gen_meta = generate_answer(
case['question'], chunks or None, case['grade'],
)
self.stdout.write(self.style.SUCCESS(' پاسخ:'))
for line in answer.splitlines():
self.stdout.write(f' {line}')
row.update({'answer': answer, 'generation': gen_meta})
return row
def _summary(self, results, empty_retrievals, opts):
total = len(results)
grounded = total - empty_retrievals
self.stdout.write(self.style.MIGRATE_HEADING('\n\nخلاصه\n'))
self.stdout.write(
f' پوششِ کتاب: {grounded} از {total} سوال تکه‌ی مرتبط پیدا کرد '
f'({100 * grounded / total:.0f}%).'
)
if empty_retrievals:
self.stdout.write(self.style.WARNING(
f' {empty_retrievals} سوال بدونِ پشتوانه ماند — یا آن فصل ingest نشده '
'یا رشته‌ی فصل با چیزی که در Qdrant است نمی‌خورد.'
))
scored = [c['score'] for r in results for c in r['chunks'] if c.get('score') is not None]
if scored:
self.stdout.write(
f' میانگینِ امتیازِ شباهت: {sum(scored) / len(scored):.3f} '
f'(کمینه {min(scored):.3f}، بیشینه {max(scored):.3f})'
)
if not opts['retrieval_only']:
tokens = sum(
(r.get('generation') or {}).get('promptTokens', 0)
+ (r.get('generation') or {}).get('completionTokens', 0)
for r in results
)
latencies = [(r.get('generation') or {}).get('latencyMs', 0) for r in results]
self.stdout.write(f' مجموعِ توکن: {tokens:,}')
if latencies:
self.stdout.write(
f' تأخیرِ تولید: میانگین {sum(latencies) / len(latencies):.0f} ms، '
f'بیشینه {max(latencies)} ms'
)
self.stdout.write(self.style.HTTP_INFO(
'\n این فرمان نمره نمی‌دهد — قضاوتِ «آیا این جواب یاد می‌دهد؟» با آدم است.'
))

View File

@@ -1,7 +1,7 @@
""" """
کشِ پاسخِ RAG برای سوالِ عیناً تکراری. کشِ پاسخِ RAG برای سوالِ عیناً تکراری.
کلید = هشِ (collection + مقدارِ فیلترِ درس/فصل + متنِ نرمال‌شده‌ی سوال). کلید = هشِ (collection + مقدارِ فیلترِ درس/فصل + پایه + متنِ نرمال‌شده‌ی سوال).
فقط مرحله‌ی گران (embedding + جستجوی Qdrant + تولیدِ GPT) رو رد می‌کنه؛ فقط مرحله‌ی گران (embedding + جستجوی Qdrant + تولیدِ GPT) رو رد می‌کنه؛
کوتای روزانه، idempotency و ثبتِ Message همچنان در view اجرا می‌شن. کوتای روزانه، idempotency و ثبتِ Message همچنان در view اجرا می‌شن.
""" """
@@ -10,7 +10,9 @@ import hashlib
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
_PREFIX = 'rag:ans:v1:' # v2: پایه هم واردِ کلید شد (پایین را ببینید). نسخه‌ی prefix عمداً جلو رفت تا
# کلیدهای v1 که پایه در آن‌ها نبود، خودبه‌خود بی‌اثر شوند.
_PREFIX = 'rag:ans:v2:'
def ttl() -> int: def ttl() -> int:
@@ -25,8 +27,15 @@ def _norm(text: str) -> str:
return ' '.join((text or '').split()).strip().lower() return ' '.join((text or '').split()).strip().lower()
def make_key(collection, filter_value, question: str) -> str: def make_key(collection, filter_value, question: str, grade_title: str = '') -> str:
raw = f'{collection}|{filter_value}|{_norm(question)}' """
⚠️ `grade_title` جزءِ کلید است چون تولیدِ پاسخ از این به بعد پایه‌آگاه است
(لحن و سطحِ توضیح از پایه می‌آید). بدونِ آن، وقتی subject/فصل مشخص نیست
collection و filter هر دو None می‌شوند و کلید فقط به متنِ سوال می‌ماند —
یعنی پاسخی که برای دانش‌آموزِ نهم ساخته شده عیناً به دانش‌آموزِ سوم
برمی‌گشت.
"""
raw = f'{collection}|{filter_value}|{(grade_title or "").strip()}|{_norm(question)}'
return _PREFIX + hashlib.sha1(raw.encode('utf-8')).hexdigest() return _PREFIX + hashlib.sha1(raw.encode('utf-8')).hexdigest()

View File

@@ -7,6 +7,7 @@
نشده یا اون درس هنوز در Qdrant نیست. نشده یا اون درس هنوز در Qdrant نیست.
""" """
import logging import logging
import time
from collections.abc import Iterator from collections.abc import Iterator
from django.conf import settings from django.conf import settings
@@ -17,70 +18,131 @@ logger = logging.getLogger(__name__)
_DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح بده'] _DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح بده']
_SYSTEM = 'تو یک معلمِ فارسی‌زبانِ دبستانی هستی و پاسخ‌ها را روشن، ساده و کوتاه می‌دهی.' # ⚠️ با هر تغییرِ معنادار در متنِ پرامپت‌های پایین، این را یکی جلو ببرید.
# روی هر پیامِ دستیار ذخیره می‌شود تا وقتی لایک/دیسلایکِ کاربرها جمع شد،
# بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست.
PROMPT_VERSION = 'tutor-v2-grade-aware'
# پایه‌ها تا ششم دبستان‌اند، هفتم تا نهم متوسطه‌ی اول و دهم به بعد متوسطه‌ی دوم.
_SECONDARY_GRADES = {'هفتم', 'هشتم', 'نهم', 'دهم', 'یازدهم', 'دوازدهم'}
def _prompt_with_context(question: str, contexts: list[str]) -> str: def _persona(grade_title: str) -> tuple[str, str]:
"""(توصیفِ معلم، توصیفِ دانش‌آموز) متناسب با پایه."""
grade = (grade_title or '').strip()
if grade in _SECONDARY_GRADES:
return f'معلمِ ریاضیِ پایه‌ی {grade}', f'دانش‌آموزِ پایه‌ی {grade}'
if grade:
return f'معلمِ پایه‌ی {grade} دبستان', f'دانش‌آموزِ پایه‌ی {grade}'
return 'معلمِ فارسی‌زبان', 'دانش‌آموز'
def system_prompt(grade_title: str = '') -> str:
"""
قبلاً «معلمِ دبستانی» ثابت بود. برای پایه‌های نهم/دهم این هم لحن را بچگانه
می‌کرد و هم مدل را وادار می‌کرد مفاهیمی مثل اتحاد، مثلثات و تابع را تا حدِ
دبستان ساده کند — یعنی دقیقاً همان‌جایی که بچه‌ها گیر می‌کنند بی‌فایده
می‌شد. حالا لحن از پایه می‌آید.
"""
teacher, _ = _persona(grade_title)
return (
f'تو یک {teacher} و فارسی‌زبان هستی. پاسخ‌ها را روشن، دقیق و '
'گام‌به‌گام می‌دهی و از نمادها و اصطلاح‌های ریاضیِ همان پایه استفاده '
'می‌کنی؛ نه ساده‌تر از سطحِ کتاب، نه پیچیده‌تر از آن.'
)
def _prompt_with_context(question: str, contexts: list[str], grade_title: str = '') -> str:
joined = '\n\n---\n\n'.join(contexts) joined = '\n\n---\n\n'.join(contexts)
teacher, student = _persona(grade_title)
return f""" return f"""
تو معلمِ دبستانی هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ تو {teacher} هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ
دانش‌آموز پاسخِ روشن و ساده بده. لازم نیست پاسخ عیناً در متن باشد؛ می‌توانی {student} پاسخِ روشن و گام‌به‌گام بده. لازم نیست پاسخ عیناً در متن باشد؛
از اطلاعاتِ متن نتیجه‌گیری و توضیح بدهی. می‌توانی از اطلاعاتِ متن نتیجه‌گیری و توضیح بدهی.
اگر مسئله چند مرحله دارد، مرحله‌ها را جدا و شماره‌گذاری‌شده بنویس.
فقط اگر متنِ درس آشکارا درباره‌ی موضوعِ دیگری‌ست و هیچ ربطی به سوال ندارد، فقط اگر متنِ درس آشکارا درباره‌ی موضوعِ دیگری‌ست و هیچ ربطی به سوال ندارد،
بنویس: «این موضوع در این درس پوشش داده نشده است.» بنویس: «این موضوع در این درس پوشش داده نشده است.»
متنِ درس: متنِ درس:
{joined} {joined}
سوالِ دانش‌آموز: {question} سوالِ {student}: {question}
""" """
def _prompt_without_context(question: str) -> str: def _prompt_without_context(question: str, grade_title: str = '') -> str:
teacher, student = _persona(grade_title)
return f""" return f"""
به این سوالِ یک دانش‌آموزِ دبستانی به فارسی، ساده و کوتاه پاسخ بده. تو {teacher} هستی. به این سوالِ یک {student} به فارسی، روشن و گام‌به‌گام
پاسخ بده.
سوال: {question} سوال: {question}
""" """
def generate_answer(question: str, context_chunks: list[dict] | None) -> tuple[str, list[str]]: def generate_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '') -> tuple[str, list[str], dict]:
""" """
context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[]. context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[].
خروجی: (answer, follow_ups) خروجی: (answer, follow_ups, meta)
`meta` همان چیزی‌ست که روی پیامِ دستیار ذخیره می‌شود: مدل، نسخه‌ی پرامپت،
توکنِ مصرف‌شده و مدتِ تولید. بدونِ اینها نه می‌شود فهمید کدام نسخه‌ی پرامپت
بهتر جواب می‌دهد، نه اینکه چقدر از سهمیه‌ی API خرج شده است.
""" """
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')] contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
prompt = _prompt_with_context(question, contexts) if contexts else _prompt_without_context(question) prompt = (
_prompt_with_context(question, contexts, grade_title) if contexts
else _prompt_without_context(question, grade_title)
)
client = clients.get_openai_client() client = clients.get_openai_client()
started = time.monotonic()
completion = client.chat.completions.create( completion = client.chat.completions.create(
model=settings.OPENAI_CHAT_MODEL, model=settings.OPENAI_CHAT_MODEL,
messages=[ messages=[
{'role': 'system', 'content': _SYSTEM}, {'role': 'system', 'content': system_prompt(grade_title)},
{'role': 'user', 'content': prompt}, {'role': 'user', 'content': prompt},
], ],
temperature=0.3, temperature=0.3,
) )
latency_ms = int((time.monotonic() - started) * 1000)
answer = (completion.choices[0].message.content or '').strip() answer = (completion.choices[0].message.content or '').strip()
if not answer: if not answer:
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.' answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
return answer, list(_DEFAULT_FOLLOW_UPS)
usage = getattr(completion, 'usage', None)
meta = {
'model': settings.OPENAI_CHAT_MODEL,
'promptVersion': PROMPT_VERSION,
'latencyMs': latency_ms,
'promptTokens': getattr(usage, 'prompt_tokens', None) or 0,
'completionTokens': getattr(usage, 'completion_tokens', None) or 0,
'hadContext': bool(contexts),
}
return answer, list(_DEFAULT_FOLLOW_UPS), meta
def stream_answer(question: str, context_chunks: list[dict] | None) -> Iterator[str]: def stream_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '') -> Iterator[str]:
""" """
همون generate_answer ولی توکن‌به‌توکن (برای SSE). فقط قطعه‌های متنِ پاسخ همون generate_answer ولی توکن‌به‌توکن (برای SSE). فقط قطعه‌های متنِ پاسخ
را yield می‌کند؛ follow_upها ثابت‌اند (_DEFAULT_FOLLOW_UPS) و caller خودش را yield می‌کند؛ follow_upها ثابت‌اند (_DEFAULT_FOLLOW_UPS) و caller خودش
اضافه‌شان می‌کند. اضافه‌شان می‌کند.
""" """
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')] contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
prompt = _prompt_with_context(question, contexts) if contexts else _prompt_without_context(question) prompt = (
_prompt_with_context(question, contexts, grade_title) if contexts
else _prompt_without_context(question, grade_title)
)
client = clients.get_openai_client() client = clients.get_openai_client()
stream = client.chat.completions.create( stream = client.chat.completions.create(
model=settings.OPENAI_CHAT_MODEL, model=settings.OPENAI_CHAT_MODEL,
messages=[ messages=[
{'role': 'system', 'content': _SYSTEM}, {'role': 'system', 'content': system_prompt(grade_title)},
{'role': 'user', 'content': prompt}, {'role': 'user', 'content': prompt},
], ],
temperature=0.3, temperature=0.3,

View File

@@ -0,0 +1,85 @@
"""
از یک `lessonId` به مقصدِ retrieval در Qdrant.
چرا لازم است: تا پیش از این، برای اینکه پاسخِ دستیار به کتاب استناد کند، اپ
باید `subject` + `grade` («نهم») + `lessonNumber` (شماره‌ی فصل) را خودش
می‌فرستاد — یعنی فرانت باید از جزئیاتِ داخلیِ نام‌گذاریِ collectionهای Qdrant
خبر می‌داشت. اگر نمی‌فرستاد (که حالتِ طبیعی است: اپ فقط می‌داند دانش‌آموز روی
کدام درس است)، retrieval کلاً رد می‌شد و مدل بدونِ پشتوانه‌ی کتاب جواب می‌داد —
بی‌هیچ خطایی، فقط جوابِ ضعیف‌تر.
حالا که Grade/Unit از روی ساختارِ خودِ کتاب ساخته می‌شوند
(`seed_curriculum_from_textbook`)، همین زنجیره کافی‌ست:
Lesson → Node → Unit(عنوان = «فصل ۴: توان و ریشه») → Grade(عنوان = «پایه نهم»)
پس اپ فقط `lessonId` می‌فرستد و بقیه اینجا درمی‌آید. مقادیرِ صریحِ درخواست
همیشه اولویت دارند؛ این فقط جای خالی را پر می‌کند.
"""
import logging
import re
from .known_collections import MATH_CHAPTERS
logger = logging.getLogger(__name__)
# «پایه نهم» / «پایهٔ نهم» / «نهم دبستان» → «نهم»
_GRADE_PREFIX = re.compile(r'^\s*پایه[‌ٔ\s]*')
def normalize_grade_title(title: str) -> str:
"""عنوانِ Grade در دیتابیس → همان رشته‌ای که در نامِ collection می‌آید."""
return _GRADE_PREFIX.sub('', (title or '').strip()).strip()
def resolve_from_lesson(lesson_id) -> dict:
"""
{'subject', 'gradeTitle', 'chapter'} — هر کدام که پیدا شود.
هیچ‌وقت استثنا نمی‌دهد: نبودنِ درس یا نخوردنِ عنوانِ فصل با کاتالوگ، یعنی
«نتوانستیم حدس بزنیم»، نه خطا — همان مسیرِ قبلی (پاسخِ عمومی) ادامه می‌یابد.
"""
if lesson_id in (None, ''):
return {}
try:
lesson_id = int(lesson_id)
except (TypeError, ValueError):
return {}
try:
from content.models import Lesson
lesson = (
Lesson.objects
.select_related('node__unit__grade')
.filter(pk=lesson_id)
.first()
)
except Exception: # noqa: BLE001 — اپ content ممکن است در دسترس نباشد
logger.exception('rag.lesson_context: خواندنِ درس %s شکست خورد', lesson_id)
return {}
if lesson is None:
return {}
unit = lesson.node.unit
grade_title = normalize_grade_title(unit.grade.title)
if not grade_title:
return {}
# عنوانِ Unit همان رشته‌ی دقیقِ فصل است؛ در کاتالوگ دنبالش می‌گردیم تا هم
# subject و هم شماره‌ی فصل دربیاید. برابریِ رشته‌ای عمدی‌ست: فیلترِ Qdrant
# هم روی همین رشته‌ی دقیق است، پس اگر اینجا نخورد آنجا هم نمی‌خورد.
unit_title = (unit.title or '').strip()
for (subject, grade), chapters in MATH_CHAPTERS.items():
if grade != grade_title:
continue
for number, title in chapters.items():
if title == unit_title:
return {'subject': subject, 'gradeTitle': grade_title, 'chapter': number}
logger.info(
'rag.lesson_context: عنوانِ فصل «%s» (پایه %s) در کاتالوگ نبود',
unit_title, grade_title,
)
return {'gradeTitle': grade_title}