diff --git a/CLAUDE.md b/CLAUDE.md index 611d286..55d8c22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -460,6 +460,19 @@ preferred setup, because there is then no key to leak. **Never let the server ru `django-insecure-…` fallback from `config/settings.py`: that string is committed here, so anyone who can read this repo can forge a token for any user.** +## متنِ بی‌اعراب، نقشه‌ی بی‌بنر، برگه‌ی پرداختِ آشنا، حذفِ حساب در «حساب کاربری» — ۲۰۲۶-۱۰-۱۴ + +- **هیچ اعراب و «ی»ِ اضافه‌ای به کاربر نمی‌رسد** (تصمیمِ شرکت): `content.plain_math.plain_persian` (تنوین می‌ماند). + بک‌اند آن را **در خروجی** می‌زند، نه در داده: `config.renderers.PlainPersianJSONRenderer` روی بایت‌های هر پاسخِ JSON، + و `rag.services.mathtext.to_plain` روی پاسخِ دستیار (استریم هم). دیتابیس، seed و ادمین متنِ اصلی را نگه می‌دارند؛ + بازنویسیِ عنوان‌ها اتصالِ سوال به درس (مسیرِ عنوان) و کلیدهای `SeedRecord` را می‌شکست. هزینه: ~۵ میلی‌ثانیه برای + هر مگابایت پاسخ (محلی)؛ زیرِ بارِ سرور اندازه نشده. پیام‌های `JsonResponse`ِ بیرون از DRF دستی تمیز شدند. تست: + `config/tests_plain_persian.py`. فرانت متن‌هایش را مستقیم پاک کرد و `plainPersian` را در `MathText` دارد. +- فرانت: بنرِ همراه و حبابِ متن بالای نقشه رفت؛ همراهِ انتخابی کنارِ جزیره‌ی فعلی و هر سه پیچ (`IslandPath`). برگه‌ی + پرداخت ردیف‌های روش با نشانِ زرین‌پال/بازار دارد و روش‌ها از `paymentMethods` در `config.js` (مرورگر/اپ) می‌آیند. + «ورود با حسابِ دیگر» حذف شد؛ حذفِ حساب در برگه‌ی «حساب کاربری» است (Google Play و App Store «پیداشدنی» می‌خواهند؛ + قاعده‌ی منتشرشده‌ای از کافه‌بازار پیدا نشد). + ## بازبینیِ پروداکشن و سخت‌کردنِ سرور — ۲۰۲۶-۱۰-۱۳ (بک‌اند `e3bddb3` و بعد) چک‌لیستِ سینیور: `~/Desktop/گزارشات Tadrisino/گزارش-۳۴/` (HTML و PDF؛ ساز در scratchpad، نه ریپو). diff --git a/ai_tutor/tests.py b/ai_tutor/tests.py index a0eb341..9e1d45f 100644 --- a/ai_tutor/tests.py +++ b/ai_tutor/tests.py @@ -65,7 +65,7 @@ class AskIdempotencyFollowUpsTests(TestCase): self.assertEqual(retry_response.status_code, 200) self.assertEqual(retry_response.data['suggestedFollowUps'], original_follow_ups) - self.assertEqual(retry_response.data['answer'], first_response.data['answer']) + self.assertEqual(retry_response.json()['answer'], first_response.json()['answer']) self.assertEqual(retry_response.data['messageId'], first_response.data['messageId']) @@ -225,7 +225,7 @@ class AskAIConcurrentIdempotencyKeyTests(TestCase): # هر دو پاسخ باید دقیقاً به همون AskIdempotency (نتیجه‌ی برنده) # اشاره کنن. self.assertEqual(outer_response.data['messageId'], inner_response.data['messageId']) - self.assertEqual(outer_response.data['answer'], inner_response.data['answer']) + self.assertEqual(outer_response.json()['answer'], inner_response.json()['answer']) # فقط یک رکوردِ AskIdempotency برای این کلید باید وجود داشته باشه. self.assertEqual( diff --git a/ai_tutor/tests_concurrency.py b/ai_tutor/tests_concurrency.py index 96a9197..01fbee9 100644 --- a/ai_tutor/tests_concurrency.py +++ b/ai_tutor/tests_concurrency.py @@ -50,7 +50,7 @@ class AskConcurrencyCapTests(TestCase): finally: concurrency.release() self.assertEqual(response.status_code, 503) - self.assertEqual(response.json()['detail'], 'دستیار الان خیلی شلوغه؛ چند ثانیه‌ی دیگه دوباره بپرس.') + self.assertEqual(response.json()['detail'], 'دستیار الان خیلی شلوغه؛ چند ثانیه دیگه دوباره بپرس.') self.assertEqual(response['Retry-After'], '10') self.assertFalse(Message.objects.exists()) # نه پیامی ساخته شد نه سهمیه‌ای رزرو شد diff --git a/config/db.py b/config/db.py index 6e9a6bc..5cb40f4 100644 --- a/config/db.py +++ b/config/db.py @@ -34,7 +34,7 @@ def release_db_connection() -> None: # هندلرِ خطای DRF (config/exceptions.py؛ هر خطای ناشناخته را خودش ۵۰۰ می‌کند و به میان‌افزار نمی‌رسد) # و DatabaseBusyMiddleware برای viewهای غیرِ DRF مثلِ /admin/. SERVER_BUSY_RETRY_AFTER = 5 -SERVER_BUSY_BODY = {'detail': 'سرور الان خیلی شلوغ است؛ چند ثانیه‌ی دیگر دوباره امتحان کنید.', 'code': 'server_busy'} +SERVER_BUSY_BODY = {'detail': 'سرور الان خیلی شلوغ است؛ چند ثانیه دیگر دوباره امتحان کنید.', 'code': 'server_busy'} def is_pool_timeout(exc) -> bool: diff --git a/config/renderers.py b/config/renderers.py new file mode 100644 index 0000000..24cd2af --- /dev/null +++ b/config/renderers.py @@ -0,0 +1,24 @@ +""" +JSONِ همه‌ی پاسخ‌های API، با متنِ فارسیِ بی‌اعراب و بی «ی»ِ اضافه (`content.plain_math.plain_persian`). + +چرا این‌جا و نه در داده: محتوا (۱۸ هزار سوال، عنوانِ فصل/جزیره/درس) و پیام‌های کد اعراب دارند. بازنویسیِ عنوان‌ها +در دیتابیس و seed اتصالِ سوال به درس را می‌شکند (سوال‌ها با مسیرِ عنوان بسته می‌شوند و دفترِ SeedRecord با هشِ +عنوان کلید می‌خورد). یک لایه در خروجی هر پیام و محتوایی را، تازه یا قدیمی، یک‌جا می‌گیرد؛ ادمین و دیتابیس همان +متنِ اصلی را نگه می‌دارند. پاسخِ زنده‌ی دستیار (SSE) از این renderer رد نمی‌شود و همین کار را در +`rag.services.mathtext.to_plain` می‌کند. + +هزینه: `plain_persian_utf8` را ببین؛ زیرِ بارِ سرور اندازه نشده. +""" +from rest_framework.renderers import JSONRenderer + +from content.plain_math import plain_persian_utf8 + + +class PlainPersianJSONRenderer(JSONRenderer): + """یک گذر روی بایت‌های JSONِ نهایی، نه روی تک‌تکِ رشته‌های داده (که روی کلِ curriculum ۴ برابرِ کندتر بود). + ensure_ascii در DRF خاموش است (UNICODE_JSON پیش‌فرض)، پس حروفِ فارسی خودشان در خروجی‌اند، نه \\uXXXX؛ + `ApiOutputTests` همین را می‌سنجد.""" + + def render(self, data, accepted_media_type=None, renderer_context=None): + body = super().render(data, accepted_media_type, renderer_context) + return plain_persian_utf8(body) if body else body diff --git a/config/settings.py b/config/settings.py index 368480b..3302b9d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -545,7 +545,7 @@ REST_FRAMEWORK = { # BrowsableAPIRenderer فقط در dev؛ در پروداکشن هم overhead داره هم یک # مرورگرِ HTMLِ API رو در معرض دید می‌ذاره. 'DEFAULT_RENDERER_CLASSES': [ - 'rest_framework.renderers.JSONRenderer', + 'config.renderers.PlainPersianJSONRenderer', *(['rest_framework.renderers.BrowsableAPIRenderer'] if DEBUG else []), ], 'DEFAULT_THROTTLE_CLASSES': [ diff --git a/config/tests_plain_persian.py b/config/tests_plain_persian.py new file mode 100644 index 0000000..ea4e8b7 --- /dev/null +++ b/config/tests_plain_persian.py @@ -0,0 +1,49 @@ +""" +اعراب و «ی»ِ اضافه به کاربر نمی‌رسد (تصمیمِ شرکت، ۲۰۲۶-۱۰-۱۴): قاعده (`content.plain_math.plain_persian`) و لایه‌ی +خروجیِ API (`config.renderers.PlainPersianJSONRenderer`). دیتابیس متنِ اصلی را نگه می‌دارد. +""" +from django.core.cache import cache +from django.test import SimpleTestCase, TestCase +from rest_framework.test import APIClient + +from content.models import Grade +from content.plain_math import plain_persian, plain_persian_utf8 + + +class PlainPersianRuleTests(SimpleTestCase): + def test_kasra_and_written_ezafe_ye_go(self): + self.assertEqual(plain_persian('از پایه‌ی سوم، ریاضیِ کتابِ خودت'), 'از پایه سوم، ریاضی کتاب خودت') + + def test_fatha_damma_shadda_sukun_and_hamza_above_go(self): + self.assertEqual(plain_persian('اَوّلُ خانهٔ مْ'), 'اول خانه م') + + def test_tanwin_stays_because_it_is_spelling(self): + self.assertEqual(plain_persian('قبلاً، موقتاً'), 'قبلاً، موقتاً') + + def test_ye_that_is_part_of_a_word_stays(self): + # «ه‌ای» (نکره) و «ه‌یی…» که حرفِ دیگری به «ی» چسبیده، اضافه نیستند. + self.assertEqual(plain_persian('جزیره‌ای تازه'), 'جزیره‌ای تازه') + self.assertEqual(plain_persian('خانه‌یمان'), 'خانه‌یمان') + + def test_idempotent_and_non_strings_untouched(self): + once = plain_persian('همه‌ی جزیره‌های پایه‌ی هشتمِ') + self.assertEqual(plain_persian(once), once) + self.assertIsNone(plain_persian(None)) + self.assertEqual(plain_persian(5), 5) + + def test_utf8_version_matches_the_text_version(self): + sample = '{"t": "همه‌ی جزیره‌ی", "u": "خانه‌یمان، قبلاً، اَوّلُ، پایه‌ی ۸", "id": 12}' + self.assertEqual(plain_persian_utf8(sample.encode()).decode(), plain_persian(sample)) + + +class ApiOutputTests(TestCase): + def setUp(self): + cache.clear() + + def test_api_json_is_clean_while_the_database_keeps_the_original(self): + Grade.objects.create(id=8, number=8, title='پایه‌ی هشتمِ', icon='x', color='#000') + body = APIClient().get('/api/grades').content.decode('utf-8') + self.assertIn('پایه هشتم', body) + self.assertNotIn('ِ', body) + self.assertNotIn('ه‌ی', body) + self.assertEqual(Grade.objects.get(id=8).title, 'پایه‌ی هشتمِ') diff --git a/content/plain_math.py b/content/plain_math.py index fdcccf4..b45f006 100644 --- a/content/plain_math.py +++ b/content/plain_math.py @@ -133,3 +133,30 @@ def convert_figures(obj, _key=None): if isinstance(obj, dict): return {k: convert_figures(v, k) for k, v in obj.items()} return obj + + +# اعراب (کسره، فتحه، ضمه، تشدید، سکون، همزه‌ی رویِ «ه») و «ی»ِ نوشته‌شده‌ی اضافه («پایه‌ی سوم» → «پایه سوم»): +# تصمیمِ شرکت (۲۰۲۶-۱۰-۱۴) این است که هیچ‌کدام به کاربر نرسد. تنوین («قبلاً») عمداً می‌ماند: جزئی از املای +# معیار است و بی‌آن غلطِ تایپی به نظر می‌رسد. «ه‌ی» فقط وقتی برداشته می‌شود که بعدش حرفِ فارسیِ دیگری نچسبیده باشد. +_FA_DIACRITICS = re.compile('[\u064C-\u0652\u0654]') +# «ه» + نیم‌فاصله + «ی» که بعدش حرفِ فارسی یا نیم‌فاصله‌ی دیگری نیست؛ با «ه» شروع می‌شود (نه lookbehind) تا موتورِ regex +# با جستجوی سریعِ پیشوند جلو برود. +_FA_EZAFE_YE = re.compile('\u0647\u200C\u06CC(?![\u0600-\u06FF\u200C])') +# همان دو قاعده روی بایت‌های UTF-8 (برای متنِ کاملِ پاسخِ JSON): اعراب D9 8C..D9 92 و D9 94 است، و D9 هرگز بایتِ +# ادامه نیست، پس با تکه‌ای از نویسه‌ی دیگر یکی نمی‌شود. حروفِ فارسی/عربی D8..DB و نیم‌فاصله E2 80 8C. +_FA_DIACRITICS_B = re.compile(rb'\xd9[\x8c-\x92\x94]') +_FA_EZAFE_YE_B = re.compile(rb'\xd9\x87\xe2\x80\x8c\xdb\x8c(?![\xd8-\xdb]|\xe2\x80\x8c)') + + +def plain_persian(text): + """متنِ فارسی بی‌اعراب و بی «ی»ِ اضافه؛ غیرِ رشته دست‌نخورده. idempotent.""" + if not isinstance(text, str) or text.isascii(): + return text + return _FA_EZAFE_YE.sub('\u0647', _FA_DIACRITICS.sub('', text)) + + +def plain_persian_utf8(body: bytes) -> bytes: + """plain_persian روی بایت‌های UTF-8، بی decode. روی متنِ کاملِ یک پاسخِ JSON درست است: هیچ‌کدام از این نویسه‌ها + جزئی از نحوِ JSON نیستند. روی curriculum.jsonِ محلی (۵ مگابایت JSON) حدودِ ۲۷ میلی‌ثانیه، در برابرِ ۵۷ برای خودِ + JSONRenderer؛ پاسخِ معمولِ /api/home چند ده کیلوبایت است.""" + return _FA_EZAFE_YE_B.sub(b'\xd9\x87', _FA_DIACRITICS_B.sub(b'', body)) diff --git a/content/tests_frontend_contract.py b/content/tests_frontend_contract.py index 92a8633..b061563 100644 --- a/content/tests_frontend_contract.py +++ b/content/tests_frontend_contract.py @@ -1,9 +1,9 @@ """ -تست‌های قراردادِ API با صفحه‌های فرانت. +تست‌های قرارداد API با صفحه‌های فرانت. -این‌ها از روی خودِ کدِ فرانت نوشته شده‌اند، نه از روی حدس: هر صفحه داده‌ی -مشخصی هاردکد داشت و اینجا بررسی می‌شود که API همان شکل را می‌دهد. بدونِ -این‌ها، هر بارِ بعدی که سریالایزری عوض شود، شکستن فقط در مرورگر معلوم می‌شد. +این‌ها از روی خود کد فرانت نوشته شده‌اند، نه از روی حدس: هر صفحه داده +مشخصی هاردکد داشت و اینجا بررسی می‌شود که API همان شکل را می‌دهد. بدون +این‌ها، هر بار بعدی که سریالایزری عوض شود، شکستن فقط در مرورگر معلوم می‌شد. """ from django.core.cache import cache from django.test import TestCase @@ -16,7 +16,7 @@ from content.models import ( class GradePickerTests(TestCase): - """صفحه‌ی انتخابِ پایه نباید پایه‌ی خالی را مثلِ پایه‌ی پر نشان دهد.""" + """صفحه انتخاب پایه نباید پایه خالی را مثل پایه پر نشان دهد.""" def setUp(self): cache.clear() @@ -34,7 +34,7 @@ class GradePickerTests(TestCase): class SkillMapTests(TestCase): - """نقشه‌ی مهارت برای هر قدم عنوان، نوع، زیرنویس و «انجام شد» می‌خواهد.""" + """نقشه مهارت برای هر قدم عنوان، نوع، زیرنویس و «انجام شد» می‌خواهد.""" def setUp(self): cache.clear() @@ -86,7 +86,7 @@ class SkillMapTests(TestCase): class LessonContentTests(TestCase): - """صفحه‌ی درس سه تکه می‌خواهد: توضیح، مثال، حلِ گام‌به‌گام.""" + """صفحه درس سه تکه می‌خواهد: توضیح، مثال، حل گام‌به‌گام.""" def setUp(self): cache.clear() @@ -117,16 +117,16 @@ class LessonContentTests(TestCase): class HomeGradeParamTests(TestCase): """ - کلاینتِ فرانت `?gradeId=` می‌فرستد ولی نسخه‌ی اولِ این اندپوینت `?grade=` - می‌خواند — و پارامترِ ناشناخته بی‌صدا نادیده گرفته می‌شد، یعنی کاربر نقشه‌ی - پایه‌ی دیگری را می‌دید بدونِ هیچ خطایی. + کلاینت فرانت `?gradeId=` می‌فرستد ولی نسخه اول این اندپوینت `?grade=` + می‌خواند — و پارامتر ناشناخته بی‌صدا نادیده گرفته می‌شد، یعنی کاربر نقشه + پایه دیگری را می‌دید بدون هیچ خطایی. """ def setUp(self): cache.clear() self.a = Grade.objects.create(title='پایه هفتم', icon='', color='') self.b = Grade.objects.create(title='پایه نهم', icon='', color='') - for grade, unit_title in ((self.a, 'فصلِ هفتم'), (self.b, 'فصلِ نهم')): + for grade, unit_title in ((self.a, 'فصل هفتم'), (self.b, 'فصل نهم')): unit = Unit.objects.create(grade=grade, title=unit_title, order=1) node = Node.objects.create(unit=unit, title='درس', order=1) Lesson.objects.create(node=node, title='نوع', intro_text='x') @@ -140,13 +140,13 @@ class HomeGradeParamTests(TestCase): return [u['title'] for u in data['units']] def test_grade_id_selects_that_grade(self): - self.assertEqual(self._titles(f'?gradeId={self.b.id}'), ['فصلِ نهم']) + self.assertEqual(self._titles(f'?gradeId={self.b.id}'), ['فصل نهم']) def test_the_removed_grade_name_is_a_400_not_a_silent_fallback(self): self.assertEqual(self.client.get(f'/api/home?grade={self.b.id}', **self.auth).status_code, 400) def test_without_a_parameter_the_users_own_grade_is_used(self): - self.assertEqual(self._titles(), ['فصلِ هفتم']) + self.assertEqual(self._titles(), ['فصل هفتم']) def test_an_unknown_grade_is_a_clean_404_not_a_silent_fallback(self): self.assertEqual(self.client.get('/api/home?gradeId=999999', **self.auth).status_code, 404) @@ -154,9 +154,9 @@ class HomeGradeParamTests(TestCase): class NodeVisualDefaultsTests(TestCase): """ - تا وقتی کسی از پنلِ ادمین رنگ نگذاشته، کارتِ مبحث نباید بی‌رنگ بماند. + تا وقتی کسی از پنل ادمین رنگ نگذاشته، کارت مبحث نباید بی‌رنگ بماند. - پیش‌فرض باید *پایدار* باشد نه تصادفی: رنگی که بینِ دو بارگذاری عوض شود، + پیش‌فرض باید *پایدار* باشد نه تصادفی: رنگی که بین دو بارگذاری عوض شود، بدتر از بی‌رنگ بودن است. """ @@ -205,8 +205,8 @@ class NodeVisualDefaultsTests(TestCase): class HomeMatchesItsSerializerTests(TestCase): """ /api/home برای سرعت dict را مستقیم برمی‌گرداند (content/views.py، HomeView)، ولی HomeResponseSerializer - هنوز قراردادِ Swagger است. اگر کسی کلیدی به پاسخ اضافه کند یا نوعی را عوض کند و سریال‌ساز را نه، - این تست می‌افتد: خروجیِ سریال‌ساز روی همان پاسخ باید عینِ خودش باشد. + هنوز قرارداد Swagger است. اگر کسی کلیدی به پاسخ اضافه کند یا نوعی را عوض کند و سریال‌ساز را نه، + این تست می‌افتد: خروجی سریال‌ساز روی همان پاسخ باید عین خودش باشد. """ def test_view_output_is_exactly_the_documented_shape(self): diff --git a/content/tests_island_progress.py b/content/tests_island_progress.py index 562b8af..c25d6e0 100644 --- a/content/tests_island_progress.py +++ b/content/tests_island_progress.py @@ -1,10 +1,10 @@ """ -جزیره‌ها روی نقشه باید با بازی کردنِ درس‌ها واقعاً تمام شوند (۲۰۲۶-۰۹-۲۵). +جزیره‌ها روی نقشه باید با بازی کردن درس‌ها واقعاً تمام شوند (۲۰۲۶-۰۹-۲۵). دو چیز با هم جلوی پیشرفت را گرفته بود: - ۱. جزیره فقط وقتی تمام می‌شد که *همه‌ی* درس‌هایش تمام شده باشند، ولی ۵۸۵ درس - هنوز سوال ندارند و اپ برایشان دکمه‌ی شروع ندارد — ۱۸۱ جزیره از ۲۶۹. - ۲. فرانت با کلیک روی جزیره همیشه lessonIds[0] را باز می‌کرد، یعنی درسِ اول + ۱. جزیره فقط وقتی تمام می‌شد که *همه* درس‌هایش تمام شده باشند، ولی ۵۸۵ درس + هنوز سوال ندارند و اپ برایشان دکمه شروع ندارد — ۱۸۱ جزیره از ۲۶۹. + ۲. فرانت با کلیک روی جزیره همیشه lessonIds[0] را باز می‌کرد، یعنی درس اول را بارها و بارها. """ from django.core.cache import cache @@ -23,8 +23,8 @@ class IslandProgressTests(TestCase): cache.clear() grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff') unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1) - self.first = Node.objects.create(unit=unit, title='جزیره‌ی اول', order=1) - self.second = Node.objects.create(unit=unit, title='جزیره‌ی دوم', order=2) + self.first = Node.objects.create(unit=unit, title='جزیره اول', order=1) + self.second = Node.objects.create(unit=unit, title='جزیره دوم', order=2) self.a = self._lesson(self.first, 'الف', with_question=True) self.empty = self._lesson(self.first, 'بی‌سوال', with_question=False) self.b = self._lesson(self.first, 'ب', with_question=True) @@ -60,11 +60,11 @@ class IslandProgressTests(TestCase): def test_tapping_the_island_opens_the_next_unfinished_lesson(self): nodes = self._nodes() - self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.a.id) - self.assertEqual(nodes['جزیره‌ی اول']['lessonIds'][0], self.a.id) + self.assertEqual(nodes['جزیره اول']['nextLessonId'], self.a.id) + self.assertEqual(nodes['جزیره اول']['lessonIds'][0], self.a.id) self._complete(self.a) - first = self._nodes()['جزیره‌ی اول'] + first = self._nodes()['جزیره اول'] # درسِ بی‌سوال رد می‌شود؛ lessonIds[0] همان است که فرانت باز می‌کند. self.assertEqual(first['nextLessonId'], self.b.id) self.assertEqual(first['lessonIds'][0], self.b.id) @@ -74,10 +74,10 @@ class IslandProgressTests(TestCase): self._complete(self.a) self._complete(self.b) nodes = self._nodes() - self.assertEqual(nodes['جزیره‌ی اول']['status'], 'completed') - self.assertEqual(nodes['جزیره‌ی دوم']['status'], 'unlocked') + self.assertEqual(nodes['جزیره اول']['status'], 'completed') + self.assertEqual(nodes['جزیره دوم']['status'], 'unlocked') # همه‌ی درس‌ها تمام شده‌اند: برای مرور، درسِ اول. - self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.a.id) + self.assertEqual(nodes['جزیره اول']['nextLessonId'], self.a.id) def test_progress_counts_only_lessons_that_can_be_played(self): self._complete(self.a) @@ -87,12 +87,12 @@ class IslandProgressTests(TestCase): def test_new_content_is_offered_but_progress_never_goes_backwards(self): self._complete(self.a) self._complete(self.b) - self.assertEqual(self._nodes()['جزیره‌ی اول']['status'], 'completed') + self.assertEqual(self._nodes()['جزیره اول']['status'], 'completed') self._question(self.empty) # حالا این درس هم بازی‌شدنی است nodes = self._nodes() # نقشه‌ی کش‌شده باطل شد: درسِ تازه پیشنهاد می‌شود… - self.assertEqual(nodes['جزیره‌ی اول']['nextLessonId'], self.empty.id) + self.assertEqual(nodes['جزیره اول']['nextLessonId'], self.empty.id) # …ولی جزیره‌ی تمام‌شده باز و جزیره‌ی بعدی قفل نمی‌شود. - self.assertEqual(nodes['جزیره‌ی اول']['status'], 'completed') - self.assertEqual(nodes['جزیره‌ی دوم']['status'], 'unlocked') + self.assertEqual(nodes['جزیره اول']['status'], 'completed') + self.assertEqual(nodes['جزیره دوم']['status'], 'unlocked') diff --git a/content/tests_node_completion.py b/content/tests_node_completion.py index f181ae3..4908c1b 100644 --- a/content/tests_node_completion.py +++ b/content/tests_node_completion.py @@ -1,9 +1,9 @@ """ -پایانِ آزمونِ جزیره → جزیره‌ی بعدی باز می‌شود. +پایان آزمون جزیره → جزیره بعدی باز می‌شود. -شکافی که این تست‌ها می‌بندند: اپ با آزمونِ چهارسوالیِ هر جزیره بازی -می‌کند، ولی جزیره فقط وقتی «تمام» حساب می‌شد که تک‌تکِ درس‌هایش (۸ تا ۱۳ -درس) جدا تمام شده باشند؛ دانش‌آموز بعد از جزیره‌ی اولِ هر فصل گیر می‌کرد. +شکافی که این تست‌ها می‌بندند: اپ با آزمون چهارسوالی هر جزیره بازی +می‌کند، ولی جزیره فقط وقتی «تمام» حساب می‌شد که تک‌تک درس‌هایش (۸ تا ۱۳ +درس) جدا تمام شده باشند؛ دانش‌آموز بعد از جزیره اول هر فصل گیر می‌کرد. """ from subscription.models import Subscription from django.core.cache import cache @@ -28,7 +28,7 @@ class NodeQuizCompleteTests(TestCase): self.next_unit = Unit.objects.create(grade=self.grade, title='فصل ۲', order=2) self.first = self._node(self.unit, 'اول', 1, lessons=5) self.second = self._node(self.unit, 'دوم', 2, lessons=3) - self.other_unit_first = self._node(self.next_unit, 'فصلِ بعد', 1, lessons=2) + self.other_unit_first = self._node(self.next_unit, 'فصل بعد', 1, lessons=2) self.user = User.objects.create(phone='09120000009', grade_id=self.grade.id) # فصلِ دوم اشتراک می‌خواهد (content.views.unit_is_free)؛ این تست ترتیبِ باز شدن را می‌سنجد، نه پرداخت. @@ -46,7 +46,7 @@ class NodeQuizCompleteTests(TestCase): return node def _answer(self, node, correct, wrong=0): - """مثلِ اپ: اول جواب‌ها ثبت می‌شوند (`correct` تای اول درست، `wrong` تای بعدی غلط)، بعد پایانِ آزمون.""" + """مثل اپ: اول جواب‌ها ثبت می‌شوند (`correct` تای اول درست، `wrong` تای بعدی غلط)، بعد پایان آزمون.""" questions = list(Question.objects.filter(lesson__node=node).order_by('id')[:correct + wrong]) for index, question in enumerate(questions): choice = question.choices.get(is_correct=index < correct) @@ -122,7 +122,7 @@ class NodeQuizCompleteTests(TestCase): self.assertEqual(body['xpEarned'], 20) def test_understating_the_question_count_does_not_buy_stars(self): - """با totalQuestions=1 و یک جوابِ درست ۳ ستاره می‌شد؛ آزمونِ استانداردِ سرور ۴ سوال است.""" + """با totalQuestions=1 و یک جواب درست ۳ ستاره می‌شد؛ آزمون استاندارد سرور ۴ سوال است.""" self._answer(self.first, correct=1, wrong=3) body = self._complete(self.first, correct=1, total=1, answer=False).json() self.assertEqual(body['stars'], 1) # ۱ از ۴ @@ -135,7 +135,7 @@ class NodeQuizCompleteTests(TestCase): self.assertEqual((response.json()['answered'], response.json()['required']), (3, 4)) def test_a_locked_island_cannot_be_finished(self): - """جزیره‌ی دوم تا اولی تمام نشده قفل است؛ حتی با جوابِ همه‌ی سوال‌هایش.""" + """جزیره دوم تا اولی تمام نشده قفل است؛ حتی با جواب همه سوال‌هایش.""" self._answer(self.second, correct=3) response = self._complete(self.second, correct=3, total=3, answer=False) self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) @@ -143,14 +143,14 @@ class NodeQuizCompleteTests(TestCase): self.assertEqual(self._statuses()['دوم'], 'locked') def test_the_first_island_of_every_unit_is_open(self): - """همان‌طور که نقشه نشان می‌دهد: اولین جزیره‌ی فصل‌ها بی‌قید باز است.""" - self.assertEqual(self._statuses()['فصلِ بعد'], 'unlocked') + """همان‌طور که نقشه نشان می‌دهد: اولین جزیره فصل‌ها بی‌قید باز است.""" + self.assertEqual(self._statuses()['فصل بعد'], 'unlocked') self.assertEqual(self._complete(self.other_unit_first, correct=2, total=2).status_code, 200) def test_an_open_island_without_questions_passes_without_answers_and_earns_nothing(self): - """چیزی برای جواب‌دادن نیست؛ رد می‌شود تا مسیرِ بقیه را نبندد (بی‌ستاره، بی‌امتیاز).""" + """چیزی برای جواب‌دادن نیست؛ رد می‌شود تا مسیر بقیه را نبندد (بی‌ستاره، بی‌امتیاز).""" empty = Node.objects.create(unit=self.next_unit, title='خالی', order=0) # اولین جزیره‌ی فصل: باز - Lesson.objects.create(node=empty, title='درسِ بی‌سوال') + Lesson.objects.create(node=empty, title='درس بی‌سوال') response = self.client.post( f'/api/nodes/{empty.pk}/quiz/complete', {'correctCount': 0, 'totalQuestions': 1}, content_type='application/json', **self.auth) @@ -159,8 +159,8 @@ class NodeQuizCompleteTests(TestCase): self.assertEqual(response.json()['nextNodeId'], str(self.other_unit_first.pk)) def test_a_locked_island_without_questions_is_still_locked(self): - empty = Node.objects.create(unit=self.unit, title='خالیِ قفل', order=9) # بعد از دو جزیره‌ی تمام‌نشده - Lesson.objects.create(node=empty, title='درسِ بی‌سوال') + empty = Node.objects.create(unit=self.unit, title='خالی قفل', order=9) # بعد از دو جزیره‌ی تمام‌نشده + Lesson.objects.create(node=empty, title='درس بی‌سوال') response = self.client.post( f'/api/nodes/{empty.pk}/quiz/complete', {'correctCount': 0, 'totalQuestions': 1}, content_type='application/json', **self.auth) @@ -197,7 +197,7 @@ class NodeQuizCompleteTests(TestCase): self.assertIn('اول', notes.first().body) def test_node_progress_is_completed_and_stays_completed(self): - """درسِ تازه در همان جزیره نباید آن را به in_progress برگرداند.""" + """درس تازه در همان جزیره نباید آن را به in_progress برگرداند.""" self._complete(self.first) lesson = self.first.lessons.first() record_answers(self.user.id, lesson=lesson) @@ -211,7 +211,7 @@ class NodeQuizCompleteTests(TestCase): self.assertEqual(progress.status, 'completed') def test_progress_percent_counts_the_islands_lessons(self): - """جزیره‌ی ۵ درسی از ۱۰ درسِ پایه → ۵۰٪، و فقط در پایه‌ی خودِ کاربر.""" + """جزیره ۵ درسی از ۱۰ درس پایه → ۵۰٪، و فقط در پایه خود کاربر.""" Grade.objects.create(title='پایه دهم', icon='', color='') # مخرج را بزرگ نکند body = self._complete(self.first).json() @@ -234,7 +234,7 @@ class NodeQuizCompleteTests(TestCase): class NodeQuizStabilityTests(TestCase): - """آزمونِ یک جزیره برای یک کاربر ثابت می‌ماند، حتی بعد از تمام‌کردنِ آزمون و حتی با استخرِ بزرگ.""" + """آزمون یک جزیره برای یک کاربر ثابت می‌ماند، حتی بعد از تمام‌کردن آزمون و حتی با استخر بزرگ.""" def setUp(self): cache.clear() diff --git a/rag/services/guardrails.py b/rag/services/guardrails.py index 7a39296..f098291 100644 --- a/rag/services/guardrails.py +++ b/rag/services/guardrails.py @@ -26,7 +26,7 @@ OFF_TOPIC_MARKER = '[خارج-از-ریاضی]' REFUSAL = ( 'من «پی» هستم و فقط توی ریاضی می‌تونم کمکت کنم 🙂\n' - 'یه سوالِ ریاضی بپرس، مثلاً از درسی که الان داری می‌خونی یا تمرینی که توش گیر کردی.' + 'یه سوال ریاضی بپرس، مثلاً از درسی که الان داری می‌خونی یا تمرینی که توش گیر کردی.' ) # اورژانسِ اجتماعیِ بهزیستی ۱۲۳ و صدای مشاورِ بهزیستی ۱۴۸۰، هر دو سراسری و رایگان. diff --git a/rag/services/mathtext.py b/rag/services/mathtext.py index 36d5637..5c06cbe 100644 --- a/rag/services/mathtext.py +++ b/rag/services/mathtext.py @@ -256,8 +256,9 @@ def _to_plain(text: str) -> str: text = re.sub(r'[ \t]+\n', '\n', text) text = re.sub(r'\n{3,}', '\n\n', text) # S_n، a_1، x^2 و توانِ پیچیده که بالا رد شد: همان تبدیلِ seed/دیتابیس (یک رفتار در همه‌جا). - from content.plain_math import convert as _plain - return _plain(text).strip() + from content.plain_math import convert as _plain, plain_persian + # بی‌اعراب و بی «ی»ِ اضافه، مثلِ همه‌ی پاسخ‌های API (config/renderers.py)؛ پاسخِ استریمی از آن renderer رد نمی‌شود. + return plain_persian(_plain(text)).strip() class StreamSanitizer: diff --git a/rag/tests.py b/rag/tests.py index 107129f..5a52105 100644 --- a/rag/tests.py +++ b/rag/tests.py @@ -104,7 +104,7 @@ class AskAIRagPathTests(TestCase): patch('rag.services.clients.get_qdrant_client', return_value=_FakeQdrant()): resp = self._ask({'userQuestion': 'یک سوالِ کلی'}) self.assertEqual(resp.status_code, 200, resp.content) - self.assertEqual(resp.data['answer'], 'پاسخِ عمومی.') + self.assertEqual(resp.data['answer'], 'پاسخ عمومی.') self.assertEqual(resp.data['usedChunks'], []) def test_provider_failure_becomes_503_and_releases_slot(self): diff --git a/rag/tests_guardrails.py b/rag/tests_guardrails.py index ed2053d..312840a 100644 --- a/rag/tests_guardrails.py +++ b/rag/tests_guardrails.py @@ -10,6 +10,8 @@ import json from types import SimpleNamespace from unittest.mock import patch +from content.plain_math import plain_persian + from django.core.cache import cache from django.test import SimpleTestCase, TestCase, override_settings from rest_framework.test import APIClient @@ -109,7 +111,8 @@ class RefusalReplacementTests(SimpleTestCase): result = {} with patch('rag.services.clients.get_openai_client', return_value=_client_answering(text, usage)): out = ''.join(stream_answer('۲+۳؟', None, 'هشتم', result=result)) - self.assertEqual(out, text) + # «بی‌تغییر» جز پاک شدنِ اعراب/«ی»ِ اضافه (content.plain_math.plain_persian)، که روی هر پاسخ اعمال می‌شود. + self.assertEqual(out, plain_persian(text)) self.assertFalse(result['refused']) self.assertEqual(result['usage'], {'promptTokens': 1200, 'completionTokens': 80}) @@ -141,7 +144,7 @@ class GuardrailThroughTheApiTests(TestCase): done = json.loads(events[-1][1]) else: text, done = response.json()['answer'], response.json() - self.assertEqual(text, guardrails.REFUSAL) + self.assertEqual(text, plain_persian(guardrails.REFUSAL)) self.assertEqual(done['usedChunks'], []) self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id, role='user').count(), 2) self.assertEqual( @@ -153,7 +156,7 @@ class GuardrailThroughTheApiTests(TestCase): patch('rag.services.clients.get_openai_client', return_value=_client_answering(MARKER)): response = self.client.post('/api/ai/ask', {'userQuestion': 'حالم خوب نیست، با دوستم دعوام شده'}, format='json') - self.assertEqual(response.json()['answer'], guardrails.REFUSAL) + self.assertEqual(response.json()['answer'], plain_persian(guardrails.REFUSAL)) self.assertEqual(response.json()['usedChunks'], []) self.assertEqual(Message.objects.get(role='assistant').text, guardrails.REFUSAL) diff --git a/rag/tests_tutor_quality.py b/rag/tests_tutor_quality.py index af8c17b..e51057c 100644 --- a/rag/tests_tutor_quality.py +++ b/rag/tests_tutor_quality.py @@ -209,7 +209,7 @@ class ConversationMemoryTests(TestCase): self.assertIn('توان منفی', retrieve_mock.call_args.args[0]) def test_a_follow_up_is_never_served_from_someone_elses_cache(self): - replies = iter(['جوابِ اولِ الف', 'جوابِ «نفهمیدم»ِ الف', 'جوابِ اولِ ب', 'جوابِ «نفهمیدم»ِ ب']) + replies = iter(['جوابِ اولِ الف', 'جوابِ «نفهمیدم»ِ الف', 'جوابِ اولِ ب', 'جواب «نفهمیدم» ب']) retrieve = patch('rag.services.retrieval.retrieve_context', return_value=([], {'gradeTitle': 'نهم'})) generate = patch('rag.services.generation.generate_answer', @@ -221,7 +221,7 @@ class ConversationMemoryTests(TestCase): conv_b = self._ask(b, 'تجزیه چیست؟').json()['conversationId'] second_b = self._ask(b, 'نفهمیدم', conv_b) - self.assertEqual(second_b.json()['answer'], 'جوابِ «نفهمیدم»ِ ب') + self.assertEqual(second_b.json()['answer'], 'جواب «نفهمیدم» ب') @override_settings(**RAG_ON)