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

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