""" کیفیتِ پاسخِ دستیار (۲۰۲۶-۰۹-۲۵): متنِ ساده به‌جای LaTeX، لحنِ سه‌سطحی، حلِ مرحله‌به‌مرحله با وارسی، حافظه‌ی گفتگو، و کنار گذاشتنِ راه‌حل‌های غلطِ کتاب. هر تست یکی از ایرادهایی را می‌پوشاند که روی جواب‌های واقعی دیده شد؛ توضیحِ هرکدام کنارِ خودِ کد است. """ from types import SimpleNamespace from unittest.mock import patch from django.core.cache import cache from django.test import SimpleTestCase, TestCase, override_settings from rest_framework.test import APIClient from accounts.authentication import issue_access_token from accounts.models import User from .services.clients import reset_clients_cache from .services.mathtext import StreamSanitizer, to_plain RAG_ON = dict( OPENAI_API_KEY='test-openai', QDRANT_API_KEY='test-qdrant', RAG_FORCE_MOCK=False, ) class PlainMathTextTests(SimpleTestCase): def test_inline_and_display_latex_become_unicode(self): text = r'مشتق \(f(x)=x^{3}-6x^2+9x\) برابر است با:' '\n' r'\[ f\'(x) = 3x^2 - 12x + 9 \]' self.assertEqual(to_plain(text), "مشتق f(x)=x³-6x²+9x برابر است با:\nf'(x) = 3x² - 12x + 9") def test_fractions_keep_their_meaning(self): # «1/2h» یعنی h/2 — مخرجِ چندنویسه‌ای باید پرانتز بگیرد. self.assertEqual(to_plain(r'\(\frac{a}{2h}\)'), 'a/(2h)') self.assertEqual(to_plain(r'\(\frac{x+1}{x-2}\)'), '(x+1)/(x-2)') self.assertEqual(to_plain(r'\(\frac{\sqrt{3}}{2}\)'), '√3/2') def test_primary_fractions_are_written_in_words(self): # در کتابِ دبستان «۲/۵» یعنی دو و نیم؛ کسر نباید با «/» نوشته شود. self.assertEqual(to_plain(r'\(\frac{3}{4}\)', fraction_word=True), '3 بر 4') def test_roots_limits_degrees_and_symbols(self): self.assertEqual(to_plain(r'\(\sqrt[3]{8} = 2\)'), '³√8 = 2') self.assertEqual(to_plain(r'\(\lim_{x \to 0} \frac{\sin x}{x} = 1\)'), 'lim(x → 0) (sin x)/x = 1') self.assertEqual(to_plain(r'\(30^\circ\)'), '30°') self.assertEqual(to_plain(r'\(a \leq b \neq c \times d\)'), 'a ≤ b ≠ c × d') self.assertEqual(to_plain('x^10 + y^2'), 'x¹⁰ + y²') def test_set_braces_survive(self): # مجموعه‌ها در ریاضیِ نهم با آکولاد نوشته می‌شوند. self.assertEqual(to_plain('A = {1, 2, 3}'), 'A = {1, 2, 3}') self.assertEqual(to_plain(r'\(A = \{1, 2\} \cup B\)'), 'A = {1, 2} ∪ B') def test_markdown_is_removed(self): text = '### مرحله ۱\n**نکته:** این مهم است\n| x | y |\n|---|---|\n| 1 | 2 |\n- اول' self.assertEqual(to_plain(text), 'مرحله ۱\nنکته: این مهم است\nx | y\n1 | 2\n• اول') def test_plain_text_passes_through(self): text = '۱) دو طرف را بر ۲ تقسیم می‌کنیم.\n۲) x = 3\n✅ جواب: 3' self.assertEqual(to_plain(text), text) def test_stream_output_equals_batch_output_however_it_is_split(self): text = ( '### حل\nاول \\(x^{2}\\) را حساب می‌کنیم:\n\\[\n\\frac{a+b}{2}\n\\]\n\n\n' '| a | b |\n|---|---|\n| 1 | 2 |\n**تمام** \\(\\sqrt{16}=4\\)' ) for step in (1, 2, 3, 7, 50): sanitizer = StreamSanitizer() out = ''.join(sanitizer.feed(text[i:i + step]) for i in range(0, len(text), step)) out += sanitizer.flush() self.assertEqual(out, to_plain(text), f'step={step}') class TutorPromptTests(SimpleTestCase): def test_three_tiers_have_different_voices(self): from rag.services.generation import system_prompt primary, middle, upper = system_prompt('سوم'), system_prompt('هشتم'), system_prompt('یازدهم') self.assertIn('دبستانی', primary) self.assertIn('سیب', primary) self.assertNotIn('دبستان', middle) self.assertIn('دبیرستان', upper) self.assertIn('دامنه', upper) def test_every_prompt_demands_full_steps_a_check_and_plain_text(self): from rag.services.generation import system_prompt for grade in ('سوم', 'نهم', 'دوازدهم', ''): prompt = system_prompt(grade) self.assertIn('مرحله‌به‌مرحله', prompt) self.assertIn('وارسی', prompt) self.assertIn('✅ جواب', prompt) self.assertIn('LaTeX', prompt) def test_decimal_notation_follows_the_book_for_primary(self): from rag.services.generation import system_prompt self.assertIn('۲/۵ یعنی', system_prompt('ششم')) self.assertIn('(3.5)', system_prompt('دهم')) def test_history_goes_before_the_new_question(self): from rag.services.generation import HISTORY_TURNS, build_messages history = [{'role': 'user', 'text': f'سوال {i}'} if i % 2 == 0 else {'role': 'assistant', 'text': f'جواب {i}'} for i in range(10)] messages = build_messages('نفهمیدم', [{'summary': 'متنِ کتاب'}], 'نهم', history) self.assertEqual(messages[0]['role'], 'system') self.assertEqual(len(messages), 1 + HISTORY_TURNS + 1) self.assertEqual(messages[-2], {'role': 'assistant', 'content': 'جواب 9'}) self.assertIn('سوالِ دانش‌آموز: نفهمیدم', messages[-1]['content']) # سوالِ کوتاهِ ادامه‌ای به سوالِ قبلیِ همان گفتگو لنگر می‌شود self.assertIn('سوالِ قبلیِ دانش‌آموز: «سوال 8»', messages[-1]['content']) self.assertIn('متنِ کتاب', messages[-1]['content']) def test_reasoning_models_get_no_temperature(self): from rag.services.generation import completion_kwargs self.assertEqual(completion_kwargs('gpt-4o-mini'), {'temperature': 0.2}) with override_settings(OPENAI_CHAT_REASONING_EFFORT='low'): self.assertEqual(completion_kwargs('o4-mini'), {'reasoning_effort': 'low'}) self.assertEqual(completion_kwargs('gpt-5-mini'), {'reasoning_effort': 'low'}) @override_settings(OPENAI_CHAT_MODEL='cheap', OPENAI_CHAT_MODEL_UPPER='strong') def test_upper_grades_can_have_their_own_model(self): from rag.services.generation import chat_model_for self.assertEqual(chat_model_for('دوازدهم'), 'strong') self.assertEqual(chat_model_for('نهم'), 'cheap') self.assertEqual(chat_model_for(''), 'cheap') @override_settings(**RAG_ON) class GenerateAnswerSanitizesTests(TestCase): def setUp(self): reset_clients_cache() def tearDown(self): reset_clients_cache() def _client(self, reply): completions = SimpleNamespace(create=lambda **kw: SimpleNamespace( choices=[SimpleNamespace(message=SimpleNamespace(content=reply))], usage=None)) return SimpleNamespace(chat=SimpleNamespace(completions=completions)) def test_latex_from_the_model_never_reaches_the_student(self): from rag.services.generation import PROMPT_VERSION, generate_answer with patch('rag.services.clients.get_openai_client', return_value=self._client(r'۱) \(x^2 = 4\)' '\n' r'✅ جواب: \(x = \pm 2\)')): answer, _f, meta = generate_answer('x² = 4', None, 'نهم') self.assertEqual(answer, '۱) x² = 4\n✅ جواب: x = ±2') self.assertTrue(meta['sanitized']) self.assertTrue(meta['rawMarkup']) self.assertEqual(meta['promptVersion'], PROMPT_VERSION) class DisputedSolutionTests(SimpleTestCase): def _payload(self, verdict): return {'generalSolution': 'روش', 'problem': 'مسئله', 'solution': 'راه‌حلِ کتاب', 'solution_verdict': verdict} def test_a_solution_the_checker_disputes_is_not_shown_to_the_tutor(self): from rag.services.retrieval import _payload_text text = _payload_text(self._payload('disagrees')) self.assertIn('مسئله', text) self.assertNotIn('راه‌حلِ کتاب:', text) def test_agreed_and_unchecked_solutions_still_go(self): from rag.services.retrieval import _payload_text for verdict in ('agrees', 'unjudgeable', None): self.assertIn('راه‌حلِ کتاب: راه‌حلِ کتاب', _payload_text(self._payload(verdict))) @override_settings(**RAG_ON, RAG_ANSWER_CACHE_TTL=300) class ConversationMemoryTests(TestCase): def setUp(self): cache.clear() reset_clients_cache() self.client = APIClient() def tearDown(self): cache.clear() def _user(self, phone): user = User.objects.create(phone=phone, is_guest=False) client = APIClient() client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') return client def _ask(self, client, text, conversation_id=None): body = {'userQuestion': text, 'subject': 'ریاضی', 'grade': 'نهم', 'lessonNumber': 4} if conversation_id: body['conversationId'] = conversation_id return client.post('/api/ai/ask', body, format='json') def test_follow_up_sees_the_conversation_and_searches_with_the_topic(self): client = self._user('09120000901') retrieve = patch('rag.services.retrieval.retrieve_context', return_value=([], {'gradeTitle': 'نهم'})) generate = patch('rag.services.generation.generate_answer', return_value=('جوابِ توان', [], {})) with retrieve as retrieve_mock, generate as generate_mock: first = self._ask(client, '۲ به توان منفی ۳ چند است؟') self._ask(client, 'نفهمیدم', first.json()['conversationId']) history = generate_mock.call_args.kwargs['history'] self.assertEqual([t['text'] for t in history], ['۲ به توان منفی ۳ چند است؟', 'جوابِ توان']) self.assertIn('توان منفی', retrieve_mock.call_args.args[0]) def test_a_follow_up_is_never_served_from_someone_elses_cache(self): replies = iter(['جوابِ اولِ الف', 'جوابِ «نفهمیدم»ِ الف', 'جوابِ اولِ ب', 'جوابِ «نفهمیدم»ِ ب']) retrieve = patch('rag.services.retrieval.retrieve_context', return_value=([], {'gradeTitle': 'نهم'})) generate = patch('rag.services.generation.generate_answer', side_effect=lambda *a, **k: (next(replies), [], {})) with retrieve, generate: a, b = self._user('09120000902'), self._user('09120000903') conv_a = self._ask(a, 'اتحاد چیست؟').json()['conversationId'] self._ask(a, 'نفهمیدم', conv_a) conv_b = self._ask(b, 'تجزیه چیست؟').json()['conversationId'] second_b = self._ask(b, 'نفهمیدم', conv_b) self.assertEqual(second_b.json()['answer'], 'جوابِ «نفهمیدم»ِ ب') @override_settings(**RAG_ON) class ProviderStateTests(TestCase): """اعتبارِ تمام‌شده باید در /api/health دیده شود، نه فقط در لاگ.""" def setUp(self): cache.clear() reset_clients_cache() user = User.objects.create(phone='09120000904', is_guest=False) self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') def tearDown(self): cache.clear() def _ask(self, generate): with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \ patch('rag.services.generation.generate_answer', **generate): return self.client.post('/api/ai/ask', {'userQuestion': 'سلام'}, format='json') def test_exhausted_credit_shows_up_in_health_and_clears_on_recovery(self): quota = RuntimeError("Error code: 403 - insufficient user quota, remaining quota: $-0.001") self.assertEqual(self._ask({'side_effect': quota}).status_code, 503) health = self.client.get('/api/health').json() self.assertEqual(health['integrations']['aiProvider']['state'], 'quota_exhausted') self.assertTrue(any('GapGPT' in w for w in health['warnings'])) self.assertEqual(self._ask({'return_value': ('جواب', [], {})}).status_code, 200) health = self.client.get('/api/health').json() self.assertEqual(health['integrations']['aiProvider']['state'], 'ok') def test_an_ordinary_network_error_does_not_claim_the_credit_is_gone(self): self.assertEqual(self._ask({'side_effect': ConnectionError('timeout')}).status_code, 503) health = self.client.get('/api/health').json() self.assertEqual(health['integrations']['aiProvider']['state'], 'ok') @override_settings(**RAG_ON) class QuestionFocusTests(TestCase): """«چرا جوابم غلط بود؟» — دستیار باید همان سوال و جوابِ دانش‌آموز را ببیند.""" def setUp(self): from content.models import Choice, Grade, Lesson, Node, Question, Unit cache.clear() grade = Grade.objects.create(title='پایه نهم', icon='', color='') node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='ف', order=1), title='ن', order=1) self.question = Question.objects.create(lesson=Lesson.objects.create(node=node, title='د'), type='multiple_choice', prompt='۲ به توان منفی ۳؟') self.right = Choice.objects.create(question=self.question, value='1/8', is_correct=True) self.wrong = Choice.objects.create(question=self.question, value='-8', is_correct=False) user = User.objects.create(phone='09120000905', is_guest=False, grade_id=grade.id) self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') def tearDown(self): cache.clear() def test_the_tutor_sees_the_question_and_the_wrong_answer(self): self.client.post(f'/api/questions/{self.question.id}/answer', {'selectedOptionId': self.wrong.id}, format='json') with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \ patch('rag.services.generation.generate_answer', return_value=('جواب', [], {})) as generate: response = self.client.post('/api/ai/ask', { 'userQuestion': 'چرا غلط شد؟', 'questionId': str(self.question.id), }, format='json') self.assertEqual(response.status_code, 200) focus = generate.call_args.kwargs['focus'] self.assertIn('۲ به توان منفی ۳؟', focus) self.assertIn('-8 (غلط)', focus) self.assertIn('جوابِ درست: 1/8', focus) def test_an_overlong_question_is_refused_before_any_paid_call(self): with patch('rag.services.generation.generate_answer') as generate: response = self.client.post('/api/ai/ask', {'userQuestion': 'x' * 2001}, format='json') self.assertEqual(response.status_code, 400) generate.assert_not_called() class SlashReadingPromptTests(SimpleTestCase): """ «/» در سوالِ دبستانی هم ممیز است هم کسر. سنجشِ ۲۰۲۶-۰۹-۲۶ نشان داد جمله‌ی «در کتاب / ممیز است» که برای دبیرستان نوشته شده بود، gpt-4.1-mini را وادار می‌کرد ۱/۷ + ۱/۸ را ۳/۵ حساب کند. این تست‌ها فقط متنِ پرامپت را می‌پایند، نه رفتارِ مدل را. """ def _user(self, question, grade, contexts=None): from rag.services.generation import build_messages return build_messages(question, contexts, grade)[-1]['content'] def test_primary_question_with_slash_carries_the_reading_rule(self): text = self._user('۱/۷ + ۱/۸ را حساب کن', 'ششم', [{'summary': 'بخشی از کتاب'}]) self.assertIn('معنای هر «/»', text) self.assertNotIn('نه کسر', text) def test_rule_also_sent_without_book_context(self): self.assertIn('معنای هر «/»', self._user('۳/۴ ÷ ۲/۵', 'ششم')) def test_upper_grades_keep_the_decimal_note_and_skip_the_rule(self): text = self._user('حد (x+1)/(x-2)', 'دهم', [{'summary': 'بخشی از کتاب'}]) self.assertIn('۳/۱۰۴ یعنی 3.104', text) self.assertNotIn('معنای هر «/»', text) def test_primary_question_without_slash_is_unchanged(self): self.assertEqual(self._user('۲۰ × ۲۰ = ؟', 'سوم'), '۲۰ × ۲۰ = ؟')