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