From fa56308a35f39f9078b4ad420cd2490fa240942f Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 13 Sep 2026 19:00:40 +0330 Subject: [PATCH] make the tutor grade-aware and its answers measurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit مهم‌ترین خواسته‌ی سینیور این بود که بسنجیم پاسخِ 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 --- ai_tutor/management/__init__.py | 0 ai_tutor/management/commands/__init__.py | 0 .../management/commands/ai_feedback_report.py | 135 +++++++++++ ...ount_message_completion_tokens_and_more.py | 78 +++++++ ai_tutor/models.py | 22 ++ ai_tutor/serializers.py | 3 + ai_tutor/views.py | 111 ++++++++- rag/management/commands/rag_eval.py | 218 ++++++++++++++++++ rag/services/answer_cache.py | 17 +- rag/services/generation.py | 94 ++++++-- rag/services/lesson_context.py | 85 +++++++ 11 files changed, 731 insertions(+), 32 deletions(-) create mode 100644 ai_tutor/management/__init__.py create mode 100644 ai_tutor/management/commands/__init__.py create mode 100644 ai_tutor/management/commands/ai_feedback_report.py create mode 100644 ai_tutor/migrations/0005_message_chunk_count_message_completion_tokens_and_more.py create mode 100644 rag/management/commands/rag_eval.py create mode 100644 rag/services/lesson_context.py diff --git a/ai_tutor/management/__init__.py b/ai_tutor/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai_tutor/management/commands/__init__.py b/ai_tutor/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai_tutor/management/commands/ai_feedback_report.py b/ai_tutor/management/commands/ai_feedback_report.py new file mode 100644 index 0000000..a856e16 --- /dev/null +++ b/ai_tutor/management/commands/ai_feedback_report.py @@ -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 '') + ) diff --git a/ai_tutor/migrations/0005_message_chunk_count_message_completion_tokens_and_more.py b/ai_tutor/migrations/0005_message_chunk_count_message_completion_tokens_and_more.py new file mode 100644 index 0000000..a81b5a1 --- /dev/null +++ b/ai_tutor/migrations/0005_message_chunk_count_message_completion_tokens_and_more.py @@ -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), + ), + ] diff --git a/ai_tutor/models.py b/ai_tutor/models.py index 86d3be4..62a4899 100644 --- a/ai_tutor/models.py +++ b/ai_tutor/models.py @@ -27,6 +27,23 @@ class Message(models.Model): question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question 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: ordering = ['created_at'] @@ -37,6 +54,11 @@ class Message(models.Model): class MessageFeedback(models.Model): message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback') 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): return f"feedback-msg{self.message_id}" diff --git a/ai_tutor/serializers.py b/ai_tutor/serializers.py index c4dcbb7..b3d6fcf 100644 --- a/ai_tutor/serializers.py +++ b/ai_tutor/serializers.py @@ -54,6 +54,9 @@ class ConversationMessagesResponseSerializer(serializers.Serializer): class FeedbackRequestSerializer(serializers.Serializer): isHelpful = serializers.BooleanField() + # اختیاری: «چرا». تنها چیزی که از خودِ 👍/👎 درنمی‌آید و برای اصلاحِ + # پرامپت واقعاً به کار می‌آید. + reason = serializers.CharField(max_length=300, required=False, allow_blank=True) class FeedbackResponseSerializer(serializers.Serializer): diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 02f4391..8a37e63 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -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) diff --git a/rag/management/commands/rag_eval.py b/rag/management/commands/rag_eval.py new file mode 100644 index 0000000..adec611 --- /dev/null +++ b/rag/management/commands/rag_eval.py @@ -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 این فرمان نمره نمی‌دهد — قضاوتِ «آیا این جواب یاد می‌دهد؟» با آدم است.' + )) diff --git a/rag/services/answer_cache.py b/rag/services/answer_cache.py index 935124c..b3514a4 100644 --- a/rag/services/answer_cache.py +++ b/rag/services/answer_cache.py @@ -1,7 +1,7 @@ """ کشِ پاسخِ RAG برای سوالِ عیناً تکراری. -کلید = هشِ (collection + مقدارِ فیلترِ درس/فصل + متنِ نرمال‌شده‌ی سوال). +کلید = هشِ (collection + مقدارِ فیلترِ درس/فصل + پایه + متنِ نرمال‌شده‌ی سوال). فقط مرحله‌ی گران (embedding + جستجوی Qdrant + تولیدِ GPT) رو رد می‌کنه؛ کوتای روزانه، idempotency و ثبتِ Message همچنان در view اجرا می‌شن. """ @@ -10,7 +10,9 @@ import hashlib from django.conf import settings from django.core.cache import cache -_PREFIX = 'rag:ans:v1:' +# v2: پایه هم واردِ کلید شد (پایین را ببینید). نسخه‌ی prefix عمداً جلو رفت تا +# کلیدهای v1 که پایه در آن‌ها نبود، خودبه‌خود بی‌اثر شوند. +_PREFIX = 'rag:ans:v2:' def ttl() -> int: @@ -25,8 +27,15 @@ def _norm(text: str) -> str: return ' '.join((text or '').split()).strip().lower() -def make_key(collection, filter_value, question: str) -> str: - raw = f'{collection}|{filter_value}|{_norm(question)}' +def make_key(collection, filter_value, question: str, grade_title: str = '') -> str: + """ + ⚠️ `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() diff --git a/rag/services/generation.py b/rag/services/generation.py index 144affd..7721c22 100644 --- a/rag/services/generation.py +++ b/rag/services/generation.py @@ -7,6 +7,7 @@ نشده یا اون درس هنوز در Qdrant نیست. """ import logging +import time from collections.abc import Iterator from django.conf import settings @@ -17,70 +18,131 @@ logger = logging.getLogger(__name__) _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) + teacher, student = _persona(grade_title) return f""" - تو معلمِ دبستانی هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ - دانش‌آموز پاسخِ روشن و ساده بده. لازم نیست پاسخ عیناً در متن باشد؛ می‌توانی - از اطلاعاتِ متن نتیجه‌گیری و توضیح بدهی. + تو {teacher} هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ + {student} پاسخِ روشن و گام‌به‌گام بده. لازم نیست پاسخ عیناً در متن باشد؛ + می‌توانی از اطلاعاتِ متن نتیجه‌گیری و توضیح بدهی. + اگر مسئله چند مرحله دارد، مرحله‌ها را جدا و شماره‌گذاری‌شده بنویس. فقط اگر متنِ درس آشکارا درباره‌ی موضوعِ دیگری‌ست و هیچ ربطی به سوال ندارد، بنویس: «این موضوع در این درس پوشش داده نشده است.» متنِ درس: {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""" - به این سوالِ یک دانش‌آموزِ دبستانی به فارسی، ساده و کوتاه پاسخ بده. + تو {teacher} هستی. به این سوالِ یک {student} به فارسی، روشن و گام‌به‌گام + پاسخ بده. سوال: {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/[]. - خروجی: (answer, follow_ups) + خروجی: (answer, follow_ups, meta) + + `meta` همان چیزی‌ست که روی پیامِ دستیار ذخیره می‌شود: مدل، نسخه‌ی پرامپت، + توکنِ مصرف‌شده و مدتِ تولید. بدونِ اینها نه می‌شود فهمید کدام نسخه‌ی پرامپت + بهتر جواب می‌دهد، نه اینکه چقدر از سهمیه‌ی API خرج شده است. """ 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() + started = time.monotonic() completion = client.chat.completions.create( model=settings.OPENAI_CHAT_MODEL, messages=[ - {'role': 'system', 'content': _SYSTEM}, + {'role': 'system', 'content': system_prompt(grade_title)}, {'role': 'user', 'content': prompt}, ], temperature=0.3, ) + latency_ms = int((time.monotonic() - started) * 1000) + answer = (completion.choices[0].message.content or '').strip() if not 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). فقط قطعه‌های متنِ پاسخ را yield می‌کند؛ follow_upها ثابت‌اند (_DEFAULT_FOLLOW_UPS) و caller خودش اضافه‌شان می‌کند. """ 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() stream = client.chat.completions.create( model=settings.OPENAI_CHAT_MODEL, messages=[ - {'role': 'system', 'content': _SYSTEM}, + {'role': 'system', 'content': system_prompt(grade_title)}, {'role': 'user', 'content': prompt}, ], temperature=0.3, diff --git a/rag/services/lesson_context.py b/rag/services/lesson_context.py new file mode 100644 index 0000000..fca65b2 --- /dev/null +++ b/rag/services/lesson_context.py @@ -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}