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

@@ -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}