Spaces:
Running
Running
File size: 21,218 Bytes
09c2269 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | """
lumen_formatting.py — конвертация markdown-подобного текста Lumen в Telegram HTML.
Вынесено из bot.py при аудите технического долга (см. пункт про монолитный
bot.py на ~3400+ строк): вся эта логика — чистые функции над строками (никакой
Telegram/Gemini/OpenRouter I/O, никакого рантайм-состояния) и поэтому один из
самых безопасных кандидатов на выделение в отдельный модуль. bot.py импортирует
из этого файла все нужные имена напрямую (см. `from lumen_formatting import ...`
в bot.py) — поведение и публичные имена (`_md_to_html`, `_scrub_latex` и т.д.)
не изменились, изменилось только физическое расположение кода.
"""
from __future__ import annotations
import re
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
def _split_table_cells(line: str) -> list[str]:
s = line.strip()
if s.startswith("|"):
s = s[1:]
if s.endswith("|"):
s = s[:-1]
return [c.strip() for c in s.split("|")]
def _convert_markdown_tables_to_lists(text: str) -> str:
"""Telegram не рендерит markdown-таблицы НИ В КАКОМ режиме (ни HTML, ни
MarkdownV2) — реальный найденный при тестировании случай: модель (особенно
некоторые модели OpenRouter) игнорирует запрет на таблицы из system_prompt.py
и всё равно генерирует '|---|---|', пользователь видит вместо аккуратной
таблицы сырую кашу из символов "|" построчно. Это защитный (второй) рубеж —
находит блоки вида "заголовок + строка-разделитель из дефисов + строки
данных" и разворачивает их в список пунктов "**Заголовок:** значение",
группируя ячейки одной строки в один пункт списка."""
if "|" not in text or "-" not in text:
return text
lines = text.split("\n")
out: list[str] = []
i = 0
n = len(lines)
while i < n:
line = lines[i]
if "|" in line and i + 1 < n and "-" in lines[i + 1] and _TABLE_SEP_RE.match(lines[i + 1]):
header_cells = _split_table_cells(line)
if len(header_cells) >= 2:
data_rows = []
j = i + 2
while j < n and "|" in lines[j] and lines[j].strip():
data_rows.append(_split_table_cells(lines[j]))
j += 1
if data_rows:
for row in data_rows:
parts = []
for h_idx, header in enumerate(header_cells):
val = row[h_idx] if h_idx < len(row) else ""
if not val:
continue
parts.append(f"**{header}:** {val}" if header else val)
if parts:
out.append("• " + "; ".join(parts))
i = j
continue
out.append(line)
i += 1
return "\n".join(out)
# ── Защитная сетка от сырого LaTeX ──────────────────────────────────────────
# Реальный найденный при калибровке случай: nemotron-3-nano-30b-a3b:free выдала
# "\[ S = \pi r^{2}, \]" и "\(x^{2}+y^{2}=r^{2}\)" вместо юникода в ответе про
# площадь круга — при том что system_prompt.py прямо запрещает LaTeX и явно
# перечисляет юникод-замены (см. раздел ФОРМАТИРОВАНИЕ). Инструкция в промпте —
# первый (и ненадёжный) рубеж; это — второй, тот же принцип, что уже применяется
# к случайным HTML-тегам в Phase 0 ниже: не полагаемся только на послушание
# модели, страхуем детерминированной пост-обработкой.
_LATEX_SUPERSCRIPT_MAP = {"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "+": "⁺", "-": "⁻", "n": "ⁿ"}
_LATEX_SUBSCRIPT_MAP = {"0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄", "5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉"}
# Порядок важен: многобуквенные команды (\times, \infty...) должны замениться
# ДО одиночного \t/\i и т.п., иначе оставшийся общий "\команда -> без бэкслеша"
# в конце срежет их раньше времени. dict сохраняет порядок вставки в Python 3.7+.
_LATEX_SYMBOL_MAP: dict[str, str] = {
r"\times": "×", r"\cdot": "·", r"\approx": "≈", r"\infty": "∞",
r"\leq": "≤", r"\le": "≤", r"\geq": "≥", r"\ge": "≥", r"\neq": "≠", r"\ne": "≠",
r"\rightarrow": "→", r"\Rightarrow": "⇒", r"\to": "→",
r"\forall": "∀", r"\exists": "∃", r"\emptyset": "∅", r"\cup": "∪", r"\cap": "∩", r"\in": "∈",
r"\pi": "π", r"\pm": "±", r"\mp": "∓", r"\sum": "∑", r"\int": "∫", r"\prod": "∏",
r"\alpha": "α", r"\beta": "β", r"\gamma": "γ", r"\Gamma": "Γ", r"\theta": "θ",
r"\lambda": "λ", r"\mu": "μ", r"\sigma": "σ", r"\Sigma": "Σ", r"\delta": "δ", r"\Delta": "Δ",
r"\phi": "φ", r"\omega": "ω", r"\Omega": "Ω",
}
def _latex_superscript(m: re.Match) -> str:
return "".join(_LATEX_SUPERSCRIPT_MAP.get(ch, ch) for ch in m.group(1))
def _latex_subscript(m: re.Match) -> str:
return "".join(_LATEX_SUBSCRIPT_MAP.get(ch, ch) for ch in m.group(1))
def _scrub_latex(text: str) -> str:
"""Конвертирует сырой LaTeX в обычный юникод-текст (или снимает разметку,
если точный эквивалент неизвестен) — ДОЛЖНА вызываться уже после того, как
настоящие блоки/спаны кода вырезаны и заменены плейсхолдерами (см. Phase 1 в
_md_to_html), иначе легитимный код с обратным слэшем (regex-паттерны, пути
Windows и т.п.) был бы испорчен."""
if "\\" not in text and "$" not in text:
return text
# Разделители-обёртки $$...$$, \[...\], \(...\) — убираем сами разделители,
# оставляя содержимое для дальнейшей посимвольной замены ниже. Одиночный
# "$...$" (инлайн-математика в LaTeX) НАМЕРЕННО не обрабатывается: найдено
# при код-ревью — если в одном сообщении встречаются и сумма в долларах, и
# настоящая формула ("цена $100, а формула $x^2$ рядом"), первый "$" суммы
# ошибочно спаривается с первым "$" формулы, и результат становится ХУЖЕ
# исходного (обрезанные суммы плюс осиротевший "$" в хвосте формулы — то есть
# именно тот класс "лишнего символа", который эта защитная сетка должна
# убирать, а не плодить). "$$...$$" безопаснее: два подряд идущих "$" без
# пробела между ними практически никогда не возникают в обычном тексте с
# суммами денег, поэтому ложные срабатывания здесь на практике не встречаются.
text = re.sub(r"\\\[(.*?)\\\]", r"\1", text, flags=re.DOTALL)
text = re.sub(r"\\\((.*?)\\\)", r"\1", text, flags=re.DOTALL)
text = re.sub(r"\$\$(.*?)\$\$", r"\1", text, flags=re.DOTALL)
# \frac{a}{b} -> a/b (одноуровневая вложенность, самый частый случай)
text = re.sub(r"\\d?frac\{([^{}]*)\}\{([^{}]*)\}", r"\1/\2", text)
# \sqrt{x} -> √x, \sqrt[n]{x} -> ⁿ√x
text = re.sub(r"\\sqrt\[([^\]]*)\]\{([^{}]*)\}", r"\1√\2", text)
text = re.sub(r"\\sqrt\{([^{}]*)\}", r"√\1", text)
for cmd, repl in _LATEX_SYMBOL_MAP.items():
text = text.replace(cmd, repl)
# x^{2} / x^2 -> x², x_{2} / x_2 -> x₂ — только короткие индексы/степени,
# чтобы случайно не тронуть код вида a^b в языках, где это не степень.
# ОСТАТОЧНЫЙ EDGE-CASE (осознанно принят, не фиксим): замена не привязана к
# "$"/"\("-разделителям и срабатывает на голое "x^2" где угодно в тексте вне
# код-блоков/код-спанов (те уже вырезаны на предыдущем шаге). Если модель
# без backtick-форматирования упомянет побитовый XOR в прозе ("5^3 даёт..."),
# это тоже превратится в "5³" — потеряв смысл XOR. Системный промпт и так
# требует оформлять код через `бэктики`/```блоки```, поэтому легитимные
# примеры кода уже защищены; голый "^" в чистой прозе почти всегда всё же
# означает именно степень, а не XOR — компромисс в пользу частого случая.
text = re.sub(r"\^\{([0-9n+\-]{1,3})\}", _latex_superscript, text)
text = re.sub(r"\^([0-9n])(?![0-9])", _latex_superscript, text)
text = re.sub(r"_\{([0-9]{1,3})\}", _latex_subscript, text)
text = re.sub(r"_([0-9])(?![0-9])", _latex_subscript, text)
# Оставшиеся одиночные \command без известного юникод-эквивалента — просто
# снимаем бэкслеш, чтобы пользователь не видел сырое "\int"/"\mathbb" и т.п.
text = re.sub(r"\\([a-zA-Z]+)", r"\1", text)
return text
# ── Маркеры списков "- текст" / "* текст" в начале строки → "• текст" ───────
# Реальный найденный при калибровке пробел: _md_to_html конвертирует **bold**,
# *italic*, `code`, ```блоки```, markdown-таблицы — но НЕ конвертирует обычные
# markdown-маркеры списков, которые system_prompt.py явно предписывает
# использовать вместо таблиц ("маркированный список"). Модель пишет "- Пункт"
# или "* Пункт" (оба — совершенно нормальный markdown), а пользователь в
# Telegram видел литеральные "-"/"*" в начале строки вместо аккуратного "•".
# Заменяем маркер целиком (а не оставляем "*" как есть) — так исключается и
# побочный риск, что одиночная "*" в начале строки случайно спарится с другой
# "*" где-то дальше в тексте и даст неверный *italic*.
_BULLET_MARKER_RE = re.compile(r"^([ \t]*)[-*][ \t]+", re.MULTILINE)
def _normalize_bullet_markers(text: str) -> str:
return _BULLET_MARKER_RE.sub(lambda m: m.group(1) + "• ", text)
def _md_to_html(text: str) -> str:
"""Convert markdown-like text to Telegram HTML.
── КОНТРАКТ ПАЙПЛАЙНА (аудит техдолга, см. пункт про фрагильность этой функции) ──
Это цепочка НЕЗАВИСИМЫХ regex-проходов поверх одного текста, а не нормальный
парсер с единым деревом разбора — каждый следующий шаг видит результат
предыдущего, и порядок шагов принципиален. Сознательное решение НЕ переписывать
это на полноценный токенизатор прямо сейчас: пайплайн уже покрыт ~20 тестами,
которые ловят именно межфазовые конфликты (см. test_scrub_latex_order_sensitive_
replacements_dont_corrupt_each_other, test_md_to_html_does_not_touch_pipes_inside_
code_block, test_scrub_latex_does_not_confuse_currency_with_math_delimiters и
т.п.) — переписывание на парсер потребовало бы повторно доказать корректность
каждого из этих уже отлаженных на реальных инцидентах edge-case'ов заново, без
реального выигрыша в надёжности, который можно было бы проверить иначе, чем тем
же самым живым продакшен-трафиком, что уже нашёл текущие edge-case'ы. Если в
будущем добавится ещё один вид форматирования и очередной межфазовый конфликт
станет реальной проблемой (а не гипотетической) — тогда и стоит пересматривать
архитектуру, а не превентивно.
Обязательный порядок фаз (нарушение порядка ломает уже отлаженные edge-case'ы):
0. Нормализация сырых HTML-тегов (<b>/<i>/<code>/<pre> и битые self-closing) в
markdown-эквивалент — ДО экранирования (шаг 2), иначе легитимные теги от
модели превратились бы в видимый мусор "<b>".
1. Код-блоки/спаны (```...```/`...`) вырезаются и заменяются плейсхолдерами —
ДО LaTeX/таблиц/списков/markdown, иначе обратные слэши и "|"/"-" внутри
реального кода (regex, пути Windows, побитовое ИЛИ) были бы испорчены.
1.3. LaTeX → юникод (_scrub_latex) — код уже вынесен шагом 1.
1.4. Маркеры списков "-"/"* " → "•" (_normalize_bullet_markers) — ДО таблиц,
чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой
и не была принята за маркер списка.
1.5. Markdown-таблицы → список пунктов (_convert_markdown_tables_to_lists) —
код и списки уже обработаны/вырезаны шагами 1/1.4.
2. HTML-экранирование остального текста (&/</>).
3. Markdown (**bold**/*italic*/~~strike~~) → HTML-теги — ПОСЛЕ экранирования,
иначе символы разметки сами могли бы быть экранированы раньше времени.
4. Код-блоки/спаны восстанавливаются из плейсхолдеров с собственным
экранированием — самыми последними, чтобы шаги 2-3 их не затронули.
Code blocks are saved first so underscores/asterisks inside them
are never treated as italic/bold markers.
"""
if not text:
return ""
# ── Phase 0: нормализация "сырых" HTML-тегов, которые модель иногда пишет
# напрямую вместо markdown (несмотря на явную инструкцию в system_prompt.py
# использовать только markdown-синтаксис) — без этого такие теги ловятся
# escape'ом на шаге 2 и показываются пользователю как видимый мусорный текст
# вида "<b>"/"<b/>" прямо в сообщении (реальный найденный при тестировании
# баг). Сначала убираем заведомо битые self-closing варианты (напр. "<b/>"),
# затем конвертируем корректные парные теги в markdown-эквивалент — дальше
# они идут по тому же (уже проверенному) конвейеру, что и обычный markdown.
text = re.sub(r"</?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
text = re.sub(r"<(?:b|strong)>(.*?)</(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<(?:i|em)>(.*?)</(?:i|em)>", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<u>(.*?)</u>", r"\1", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<s>(.*?)</s>", r"~~\1~~", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<pre>(.*?)</pre>", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<code>(.*?)</code>", r"`\1`", text, flags=re.IGNORECASE | re.DOTALL)
# ── Phase 1: Save code spans/blocks before any processing ────────────────
_saved: dict[str, str] = {}
_counter = [0]
def _save_block(m: re.Match) -> str:
key = f"\x00CB{_counter[0]}\x00"
_counter[0] += 1
_saved[key] = m.group(0)
return key
text = re.sub(r"```[a-zA-Z0-9]*\n.*?\n```", _save_block, text, flags=re.DOTALL)
text = re.sub(r"`[^`\n]+`", _save_block, text)
# ── Phase 1.3: сырой LaTeX → юникод (см. _scrub_latex выше) — код уже
# вынесен на предыдущем шаге, поэтому обратные слэши в реальном коде
# (regex, пути Windows и т.п.) не затрагиваются.
text = _scrub_latex(text)
# ── Phase 1.4: маркеры списков "- "/"* " → "• " (см. _normalize_bullet_
# markers выше) — ДО таблиц и ДО Phase 3, чтобы не путаться с "**bold**" и
# чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой.
text = _normalize_bullet_markers(text)
# ── Phase 1.5: markdown-таблицы → список пунктов (см. _convert_markdown_
# tables_to_lists выше) — код уже вынесен на предыдущем шаге, поэтому "|"
# внутри кода (например, битовое ИЛИ в Rust/C) сюда не попадёт.
text = _convert_markdown_tables_to_lists(text)
# ── Phase 2: HTML-escape the rest ────────────────────────────────────────
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
# ── Phase 3: Apply markdown ───────────────────────────────────────────────
text = re.sub(r"(\*\*|__)(.*?)\1", r"<b>\2</b>", text, flags=re.DOTALL)
text = re.sub(r"(\*|_)(.*?)\1", r"<i>\2</i>", text)
text = re.sub(r"~~(.*?)~~", r"<s>\1</s>", text)
# ── Phase 4: Restore code blocks with proper escaping ────────────────────
for key, orig in _saved.items():
if orig.startswith("```"):
m = re.match(r"```[a-zA-Z0-9]*\n(.*)\n```", orig, re.DOTALL)
inner = m.group(1) if m else orig[3:-3]
else:
inner = orig[1:-1]
inner = inner.replace("&", "&").replace("<", "<").replace(">", ">")
tag = "pre" if orig.startswith("```") else "code"
text = text.replace(key, f"<{tag}>{inner}</{tag}>")
return text
|