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:
2026-10-14 16:57:17 +03:30
parent 7b241c1aac
commit 1c7c4b8eca
16 changed files with 181 additions and 64 deletions

View File

@@ -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، نه ریپو).

View File

@@ -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(

View File

@@ -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()) # نه پیامی ساخته شد نه سهمیه‌ای رزرو شد

View File

@@ -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
View 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

View File

@@ -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': [

View 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, 'پایه‌ی هشتمِ')

View File

@@ -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))

View File

@@ -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):

View File

@@ -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')

View File

@@ -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()

View File

@@ -26,7 +26,7 @@ OFF_TOPIC_MARKER = '[خارج-از-ریاضی]'
REFUSAL = (
'من «پی» هستم و فقط توی ریاضی می‌تونم کمکت کنم 🙂\n'
'یه سوالِ ریاضی بپرس، مثلاً از درسی که الان داری می‌خونی یا تمرینی که توش گیر کردی.'
'یه سوال ریاضی بپرس، مثلاً از درسی که الان داری می‌خونی یا تمرینی که توش گیر کردی.'
)
# اورژانسِ اجتماعیِ بهزیستی ۱۲۳ و صدای مشاورِ بهزیستی ۱۴۸۰، هر دو سراسری و رایگان.

View File

@@ -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:

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)