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:
2026-09-25 06:24:13 +03:30
parent fbdb9495ee
commit 42783f9ba4
56 changed files with 2792 additions and 326 deletions

308
rag/services/mathtext.py Normal file
View 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