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:
218
rag/management/commands/rag_eval.py
Normal file
218
rag/management/commands/rag_eval.py
Normal 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 این فرمان نمره نمیدهد — قضاوتِ «آیا این جواب یاد میدهد؟» با آدم است.'
|
||||
))
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
85
rag/services/lesson_context.py
Normal file
85
rag/services/lesson_context.py
Normal 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}
|
||||
Reference in New Issue
Block a user