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

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