Files
MathApp/rag/services/lesson_context.py
Zareian fa56308a35 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>
2026-09-13 19:00:40 +03:30

86 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
از یک `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}