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,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()

View File

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

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}