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>
313 lines
14 KiB
Python
313 lines
14 KiB
Python
"""
|
||
تبدیلِ LaTeX و markdown در پاسخِ دستیار به متنِ ساده با نمادهای یونیکد.
|
||
|
||
چرا لازم است: صفحهی «از پی بپرس» پاسخ را همانطور که هست، بهصورتِ متنِ خام
|
||
نشان میدهد. مدلها برای ریاضی عادت دارند `\\( \\frac{1}{2} \\)` و `### مرحله ۱`
|
||
بنویسند؛ روی گوشی اینها عیناً دیده میشوند و فرمولِ حسابان به رشتهای از
|
||
بکاسلش و آکولاد تبدیل میشود. پرامپت از مدل میخواهد متنِ ساده بنویسد، ولی
|
||
مدل همیشه گوش نمیدهد — این ماژول تورِ ایمنیِ قطعی پشتِ آن است.
|
||
|
||
قاعدهی مهم: آکولادی که جزوِ LaTeX نیست دست نمیخورد. مجموعهها در ریاضیِ نهم
|
||
با `{1, 2, 3}` نوشته میشوند و نباید پاک شوند.
|
||
"""
|
||
import re
|
||
from contextvars import ContextVar
|
||
|
||
# برای دبستان «/» ممیزِ اعشار است (مثلِ کتابشان)، پس کسر «۳ بر ۴» نوشته میشود.
|
||
_FRACTION_WORD: ContextVar[bool] = ContextVar('fraction_word', default=False)
|
||
|
||
_SUPERSCRIPT = str.maketrans('0123456789+-=()nixyk', '⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱˣʸᵏ')
|
||
_SUBSCRIPT = str.maketrans('0123456789+-=()', '₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎')
|
||
_SUP_CHARS = set('0123456789+-=()nixyk')
|
||
_SUB_CHARS = set('0123456789+-=()')
|
||
|
||
_SYMBOLS = {
|
||
'times': '×', 'cdot': '·', 'div': '÷', 'pm': '±', 'mp': '∓',
|
||
'leq': '≤', 'le': '≤', 'geq': '≥', 'ge': '≥', 'neq': '≠', 'ne': '≠',
|
||
'approx': '≈', 'simeq': '≃', 'equiv': '≡', 'infty': '∞',
|
||
'pi': 'π', 'alpha': 'α', 'beta': 'β', 'gamma': 'γ', 'theta': 'θ',
|
||
'Delta': 'Δ', 'delta': 'δ', 'lambda': 'λ', 'mu': 'μ', 'sigma': 'σ',
|
||
'Sigma': 'Σ', 'phi': 'φ', 'varphi': 'φ', 'omega': 'ω', 'epsilon': 'ε',
|
||
'varepsilon': 'ε', 'rho': 'ρ', 'tau': 'τ',
|
||
'rightarrow': '→', 'to': '→', 'leftarrow': '←', 'Rightarrow': '⇒',
|
||
'implies': '⇒', 'Leftrightarrow': '⇔', 'iff': '⇔', 'mapsto': '↦',
|
||
'in': '∈', 'notin': '∉', 'subset': '⊂', 'subseteq': '⊆', 'supset': '⊃',
|
||
'cup': '∪', 'cap': '∩', 'emptyset': '∅', 'varnothing': '∅',
|
||
'forall': '∀', 'exists': '∃', 'angle': '∠', 'perp': '⊥', 'parallel': '∥',
|
||
'triangle': '△', 'circ': '∘', 'degree': '°', 'prime': '′',
|
||
'ldots': '…', 'dots': '…', 'cdots': '⋯', 'int': '∫', 'sum': 'Σ',
|
||
'prod': '∏', 'therefore': '∴', 'propto': '∝', 'neg': '¬',
|
||
'quad': ' ', 'qquad': ' ', 'lbrace': '{', 'rbrace': '}',
|
||
'setminus': '−', 'mid': '|', 'vert': '|',
|
||
}
|
||
_PREFIX_SIGNS = {'pm', 'mp', 'neg', 'angle', 'triangle', 'forall', 'exists'}
|
||
_BLACKBOARD = {'R': 'ℝ', 'N': 'ℕ', 'Z': 'ℤ', 'Q': 'ℚ', 'C': 'ℂ'}
|
||
# \sin و \log و ... فقط بکاسلششان حذف میشود.
|
||
_FUNCTIONS = {
|
||
'sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'log', 'ln', 'exp', 'lim',
|
||
'max', 'min', 'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'det', 'gcd',
|
||
}
|
||
# دستورهایی که فقط محتوای آرگومانشان میماند.
|
||
_UNWRAP = {'text', 'textbf', 'textit', 'mathrm', 'mathbf', 'mathit', 'operatorname',
|
||
'boxed', 'displaystyle', 'mbox', 'underline', 'bm', 'textrm'}
|
||
_DROP = {'left', 'right', 'big', 'Big', 'bigg', 'Bigg', 'displaystyle', 'limits',
|
||
'nonumber', 'hline', 'noindent'}
|
||
|
||
|
||
def _read_group(s: str, i: int) -> tuple[str, int]:
|
||
"""از s[i] یک آرگومانِ LaTeX میخواند: {…} با آکولادِ تودرتو، یا یک نویسه."""
|
||
while i < len(s) and s[i] == ' ':
|
||
i += 1
|
||
if i >= len(s):
|
||
return '', i
|
||
if s[i] != '{':
|
||
if s[i] == '\\':
|
||
m = re.match(r'\\[A-Za-z]+', s[i:])
|
||
if m:
|
||
return m.group(0), i + len(m.group(0))
|
||
return s[i], i + 1
|
||
depth, j = 0, i
|
||
while j < len(s):
|
||
if s[j] == '\\' and j + 1 < len(s):
|
||
j += 2
|
||
continue
|
||
if s[j] == '{':
|
||
depth += 1
|
||
elif s[j] == '}':
|
||
depth -= 1
|
||
if depth == 0:
|
||
return s[i + 1:j], j + 1
|
||
j += 1
|
||
return s[i + 1:], len(s)
|
||
|
||
|
||
_ATOM = re.compile(r'√?[\d.]+|√?\w|\(.*\)')
|
||
|
||
|
||
def _needs_parens(expr: str) -> bool:
|
||
"""«2h» در مخرج باید (2h) شود، وگرنه 1/2h یعنی h/2."""
|
||
return not _ATOM.fullmatch(expr.strip())
|
||
|
||
|
||
def _script(body: str, table, allowed: set, marker: str) -> str:
|
||
if body and all(ch in allowed for ch in body):
|
||
return body.translate(table)
|
||
return f'{marker}({body})' if len(body) > 1 else f'{marker}{body}'
|
||
|
||
|
||
def _convert(s: str) -> str:
|
||
"""یک عبارتِ LaTeX را (بازگشتی) به یونیکد تبدیل میکند."""
|
||
out: list[str] = []
|
||
i = 0
|
||
while i < len(s):
|
||
ch = s[i]
|
||
if ch == '\\':
|
||
if i + 1 < len(s) and not s[i + 1].isalpha():
|
||
nxt = s[i + 1]
|
||
if nxt == '\\':
|
||
out.append('\n')
|
||
elif nxt in '{}%$&#_':
|
||
out.append(nxt)
|
||
elif nxt in ',;:! ':
|
||
out.append(' ' if nxt != '!' else '')
|
||
else:
|
||
out.append(nxt)
|
||
i += 2
|
||
continue
|
||
m = re.match(r'\\([A-Za-z]+)', s[i:])
|
||
name = m.group(1)
|
||
i += len(m.group(0))
|
||
if name == 'frac' or name in ('dfrac', 'tfrac'):
|
||
num, i = _read_group(s, i)
|
||
den, i = _read_group(s, i)
|
||
num, den = _convert(num), _convert(den)
|
||
num = f'({num})' if _needs_parens(num) else num
|
||
den = f'({den})' if _needs_parens(den) else den
|
||
out.append(f'{num} بر {den}' if _FRACTION_WORD.get() else f'{num}/{den}')
|
||
elif name == 'sqrt':
|
||
index = ''
|
||
if i < len(s) and s[i] == '[':
|
||
close = s.find(']', i)
|
||
if close != -1:
|
||
index, i = s[i + 1:close], close + 1
|
||
body, i = _read_group(s, i)
|
||
body = _convert(body)
|
||
root = (index.translate(_SUPERSCRIPT) if index else '') + '√'
|
||
out.append(root + (f'({body})' if len(body) > 1 else body))
|
||
elif name == 'mathbb':
|
||
body, i = _read_group(s, i)
|
||
out.append(_BLACKBOARD.get(body, body))
|
||
elif name in ('vec', 'overrightarrow', 'overline', 'bar', 'widehat', 'hat'):
|
||
# فلشِ بالای حرف (U+20D7) در فونتِ بسیاری از گوشیها دیده نمیشود یا روی
|
||
# حرف مینشیند؛ کتابهای درسی هم «بردار a» مینویسند. هیچ نشانی نمیگذاریم.
|
||
body, i = _read_group(s, i)
|
||
out.append(_convert(body))
|
||
body, i = _read_group(s, i)
|
||
out.append(_convert(body))
|
||
elif name in ('begin', 'end'):
|
||
_env, i = _read_group(s, i)
|
||
elif name in _UNWRAP:
|
||
if i < len(s) and s[i] == '{':
|
||
body, i = _read_group(s, i)
|
||
out.append(_convert(body))
|
||
elif name in _DROP:
|
||
# \left( → ( — خودِ پرانتز در دورِ بعدِ حلقه نوشته میشود.
|
||
if name in ('left', 'right') and i < len(s) and s[i] == '.':
|
||
i += 1
|
||
elif name in _SYMBOLS:
|
||
out.append(_SYMBOLS[name])
|
||
# «\pm 2» → «±2»: نمادِ یکطرفه به عددش میچسبد.
|
||
if name in _PREFIX_SIGNS:
|
||
while i < len(s) and s[i] == ' ':
|
||
i += 1
|
||
elif name in _FUNCTIONS:
|
||
out.append(name)
|
||
else:
|
||
out.append(name)
|
||
elif ch in '^_':
|
||
# مدل «x^10» را به معنیِ ده مینویسد، نه «x¹0» — رقمها با هم خوانده میشوند.
|
||
digits = re.match(r'\d+', s[i + 1:])
|
||
if digits:
|
||
body, i = digits.group(0), i + 1 + len(digits.group(0))
|
||
else:
|
||
body, i = _read_group(s, i + 1)
|
||
if ch == '^' and body in ('\\circ', 'circ', '°'):
|
||
out.append('°')
|
||
continue
|
||
if ch == '^' and body in ('\\prime', "'"):
|
||
out.append('′')
|
||
continue
|
||
body = _convert(body)
|
||
if ch == '_' and ''.join(out).endswith('lim'):
|
||
out.append(f'({body})')
|
||
continue
|
||
if ch == '^':
|
||
out.append(_script(body, _SUPERSCRIPT, _SUP_CHARS, '^'))
|
||
else:
|
||
out.append(_script(body, _SUBSCRIPT, _SUB_CHARS, '_'))
|
||
elif ch == '{':
|
||
# آکولادِ گروهبندیِ LaTeX (بعد از پاک شدنِ دستورِ صاحبش)
|
||
body, i = _read_group(s, i)
|
||
out.append(_convert(body))
|
||
elif ch == '&':
|
||
i += 1
|
||
else:
|
||
out.append(ch)
|
||
i += 1
|
||
return ''.join(out)
|
||
|
||
|
||
# \( … \) و \[ … \] و $$ … $$ و $ … $ — محتوا تبدیل و جداکننده حذف میشود.
|
||
_MATH_SPANS = re.compile(
|
||
r'\\\[(.+?)\\\]|\\\((.+?)\\\)|\$\$(.+?)\$\$|(?<![\w$])\$([^$\n]+?)\$(?!\w)',
|
||
re.DOTALL,
|
||
)
|
||
# LaTeXِ بیجداکننده که مدل گاهی وسطِ متن مینویسد: \frac{..}{..}، x^{2}، \sqrt{..}
|
||
_BARE_LATEX = re.compile(r'\\[A-Za-z{}]|[\^_]\{|(?<=[\w)])\^\d')
|
||
|
||
|
||
def _math_span(match: re.Match) -> str:
|
||
body = next(g for g in match.groups() if g is not None)
|
||
# بلوکِ نمایشی را مدل خودش در خطِ جدا مینویسد؛ خطِ خالیِ اضافه نمیسازیم.
|
||
if '\\\\' in body: # چندخطی (\\ یا cases/aligned)
|
||
return '\n'.join(' '.join(line.split()) for line in _convert(body).strip().split('\n'))
|
||
return ' '.join(_convert(body).split())
|
||
|
||
|
||
def _strip_markdown(text: str) -> str:
|
||
lines = []
|
||
for line in text.split('\n'):
|
||
stripped = line.strip()
|
||
if re.fullmatch(r'\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?', stripped):
|
||
continue # خطِ جداکنندهی جدولِ markdown
|
||
if stripped.startswith('|') and stripped.endswith('|') and stripped.count('|') >= 2:
|
||
cells = [c.strip() for c in stripped.strip('|').split('|')]
|
||
line = ' | '.join(cells)
|
||
line = re.sub(r'^\s{0,3}#{1,6}\s+', '', line)
|
||
line = re.sub(r'^(\s*)[-*]\s+', r'\1• ', line)
|
||
line = re.sub(r'^\s*(-{3,}|\*{3,}|_{3,})\s*$', '', line)
|
||
line = re.sub(r'\*\*(.+?)\*\*', r'\1', line)
|
||
line = re.sub(r'__(.+?)__', r'\1', line)
|
||
line = line.replace('`', '')
|
||
lines.append(line.strip()) # تورفتگی در حبابِ راستبهچپ معنایی ندارد
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def to_plain(text: str, *, fraction_word: bool = False) -> str:
|
||
"""
|
||
پاسخِ مدل → متنِ سادهی قابلِ خواندن روی گوشی.
|
||
fraction_word: کسرِ LaTeX بهصورتِ «a بر b» (برای دبستان) بهجای a/b.
|
||
"""
|
||
if not text:
|
||
return text
|
||
token = _FRACTION_WORD.set(fraction_word)
|
||
try:
|
||
return _to_plain(text)
|
||
finally:
|
||
_FRACTION_WORD.reset(token)
|
||
|
||
|
||
def _to_plain(text: str) -> str:
|
||
text = _MATH_SPANS.sub(_math_span, text)
|
||
if _BARE_LATEX.search(text):
|
||
text = '\n'.join(_convert(line) if _BARE_LATEX.search(line) else line
|
||
for line in text.split('\n'))
|
||
text = _strip_markdown(text)
|
||
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, plain_persian
|
||
# بیاعراب و بی «ی»ِ اضافه، مثلِ همهی پاسخهای API (config/renderers.py)؛ پاسخِ استریمی از آن renderer رد نمیشود.
|
||
return plain_persian(_plain(text)).strip()
|
||
|
||
|
||
class StreamSanitizer:
|
||
"""
|
||
همان to_plain برای پاسخِ استریمی. یک دستورِ LaTeX ممکن است بینِ دو تکهی
|
||
استریم نصف شود (`\\fr` + `ac{1}{2}`)، پس متن تا پایانِ هر خط نگه داشته و
|
||
خطبهخط تبدیل میشود؛ یک بلوکِ `\\[ … \\]` چندخطی هم تا بسته شدنش نگه
|
||
داشته میشود.
|
||
"""
|
||
|
||
def __init__(self, *, fraction_word: bool = False):
|
||
self._fraction_word = fraction_word
|
||
self._buffer = ''
|
||
self._emitted_any = False
|
||
self._newlines = 0 # خطهای خالیِ معلق؛ مثلِ to_plain حداکثر دوتا
|
||
|
||
def _ready_cut(self) -> int:
|
||
cut = self._buffer.rfind('\n')
|
||
if cut == -1:
|
||
return -1
|
||
head = self._buffer[:cut + 1]
|
||
# بلوکِ نمایشیِ باز را نصفه نفرست.
|
||
if head.count('\\[') > head.count('\\]') or head.count('$$') % 2:
|
||
return -1
|
||
return cut + 1
|
||
|
||
def feed(self, delta: str) -> str:
|
||
self._buffer += delta
|
||
cut = self._ready_cut()
|
||
if cut == -1:
|
||
return ''
|
||
head, self._buffer = self._buffer[:cut], self._buffer[cut:]
|
||
return self._emit(head, final=False)
|
||
|
||
def flush(self) -> str:
|
||
head, self._buffer = self._buffer, ''
|
||
return self._emit(head, final=True)
|
||
|
||
def _emit(self, chunk: str, *, final: bool) -> str:
|
||
plain = to_plain(chunk, fraction_word=self._fraction_word)
|
||
if not plain:
|
||
# خطِ واقعاً خالی خطِ خالی است؛ خطی که محتوایش حذف شد (جداکنندهی
|
||
# جدول) مثلِ to_plain هیچ ردی نمیگذارد.
|
||
if not chunk.strip():
|
||
self._newlines += chunk.count('\n')
|
||
return ''
|
||
leading = len(chunk) - len(chunk.lstrip('\n'))
|
||
prefix = '\n' * min(self._newlines + leading, 2) if self._emitted_any else ''
|
||
self._emitted_any = True
|
||
self._newlines = 0 if final else len(chunk) - len(chunk.rstrip('\n'))
|
||
return prefix + plain
|