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

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