مهمترین خواستهی سینیور این بود که بسنجیم پاسخِ 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>
219 lines
10 KiB
Python
219 lines
10 KiB
Python
"""
|
|
سنجشِ کیفیتِ پاسخهای دستیار روی دادهٔ واقعی.
|
|
|
|
سینیور: «مهمترین بخش پروژه اینه که ببینیم اون چیزی که 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 این فرمان نمره نمیدهد — قضاوتِ «آیا این جواب یاد میدهد؟» با آدم است.'
|
|
))
|