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), иначе легитимные теги от
         модели превратились бы в видимый мусор "&lt;b&gt;".
      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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

    # ── 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        tag = "pre" if orig.startswith("```") else "code"
        text = text.replace(key, f"<{tag}>{inner}</{tag}>")

    return text