API output without Persian diacritics or written ezafe ی (company decision); tutor answers too
plain_persian() strips kasra/fatha/damma/shadda/sukun/hamza-above and the written «هی» ezafe, keeping tanwin. Applied at output, not in data: PlainPersianJSONRenderer runs one byte-level pass over every JSON response (~5 ms per MB locally), and to_plain() cleans tutor answers including the stream. Rewriting titles in the DB/seed would have broken question binding by title path and SeedRecord keys. Tests updated to expect the cleaned text; config/tests_plain_persian.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
13
CLAUDE.md
13
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، نه ریپو).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()) # نه پیامی ساخته شد نه سهمیهای رزرو شد
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
24
config/renderers.py
Normal file
24
config/renderers.py
Normal file
@@ -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
|
||||
@@ -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': [
|
||||
|
||||
49
config/tests_plain_persian.py
Normal file
49
config/tests_plain_persian.py
Normal file
@@ -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, 'پایهی هشتمِ')
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -26,7 +26,7 @@ OFF_TOPIC_MARKER = '[خارج-از-ریاضی]'
|
||||
|
||||
REFUSAL = (
|
||||
'من «پی» هستم و فقط توی ریاضی میتونم کمکت کنم 🙂\n'
|
||||
'یه سوالِ ریاضی بپرس، مثلاً از درسی که الان داری میخونی یا تمرینی که توش گیر کردی.'
|
||||
'یه سوال ریاضی بپرس، مثلاً از درسی که الان داری میخونی یا تمرینی که توش گیر کردی.'
|
||||
)
|
||||
|
||||
# اورژانسِ اجتماعیِ بهزیستی ۱۲۳ و صدای مشاورِ بهزیستی ۱۴۸۰، هر دو سراسری و رایگان.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user