tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown, with rag/services/mathtext.py converting whatever still slips through; full step-by-step solutions with a check before the final answer; three tones by grade; the last 6 messages of the conversation go to the model and follow-ups are no longer served from another user's cache; book solutions the checker disputes are kept out of the context; a questionId gives the tutor the practice question and the student's wrong answer. /api/health reports when the AI credit runs out. rag_model_benchmark compares chat models on verified book problems (run on the server; credit ran out mid-run). Map: only lessons that have questions count toward finishing an island, lessonIds[0] / nextLessonId is always the next lesson to play, and a finished island stays finished. The Seyar characters (sigma/delta/pi) are seeded. Audit fixes, each reproduced before fixing: - the plan is stored on the order, so paying a monthly order after starting a yearly checkout no longer grants a year - opening checkout no longer demotes an active subscription; renewals extend from the current expiry; expired subscriptions are not reported active - one discount code per order and only before the gateway; a 100% code completes the purchase; lost Zarinpal confirmations are re-verified - the unused /api/payment/webhook "mark paid" endpoint is removed - XP counts only answers recorded on the server - 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout - a guest who signs up keeps their account and progress (test phones excluded) - affiliate commission on the first real payment; guests excluded - parents with several children no longer get a 500 - SQLite runs transactions IMMEDIATE, since it ignores select_for_update - admin login is rate-limited; display names drop phone numbers and links; userQuestion is capped at 2000 characters - server-side heart enforcement exists behind HEARTS_ENFORCED (off until the frontend handles 403 no_hearts) Not done on purpose: rotating the server's secret key and moving to Postgres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
308
rag/services/mathtext.py
Normal file
308
rag/services/mathtext.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
تبدیلِ 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'):
|
||||
body, i = _read_group(s, i)
|
||||
out.append(_convert(body) + '⃗')
|
||||
elif name in ('overline', 'bar', 'widehat', 'hat'):
|
||||
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)
|
||||
return 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
|
||||
Reference in New Issue
Block a user