SilverElixir commited on
Commit
9d7cca0
·
1 Parent(s): 339b914

Instant dots, buying-advice routing, br handling, plain-fallback cleanup, live-log model updates

Browse files
bot.py CHANGED
@@ -401,12 +401,12 @@ STREAM_TYPING_MAX_CATCHUP_TICKS = int(os.getenv("STREAM_TYPING_MAX_CATCHUP_TICKS
401
  # куска — всегда минимум из двух.
402
  FIRST_CHUNK_TIMEOUT_SEC = float(os.getenv("FIRST_CHUNK_TIMEOUT_SEC", "12"))
403
  # Анимация ожидания ("бегущие точки") в плейсхолдере, пока не пришёл первый
404
- # кусок стрима: первая смена кадра не раньше _DOTS_START_AFTER_SEC (мгновенно
405
- # ответившая модель анимации вообще не покажет), дальше — кадр каждые
406
- # _DOTS_TICK_SEC. Интервалы подобраны под лимит Telegram (не чаще правки в
407
- # секунду) с запасом; правки идут только показывать нечего (до первого куска),
408
- # поэтому с показом текста не конфликтуют.
409
- _DOTS_START_AFTER_SEC = 2.5
410
  _DOTS_TICK_SEC = 1.2
411
  _DOTS_FRAMES = (".", "..", "…")
412
  # RICH_MESSAGES_ENABLED — отправка финальных ответов через sendRichMessage /
@@ -628,7 +628,7 @@ async def _close_sessions() -> None:
628
  # именно ради старых тестов на `bot.X`, но с переездом тестов на прямой импорт
629
  # модуля этот ре-экспорт стал мёртвым (см. аудит техдолга, 26 августа 2026) и
630
  # убран вместе с соответствующим `__all__`.
631
- from lumen_formatting import _md_to_html, _md_to_rich_html, _split_text_chunks
632
 
633
  _PRUNE_SENTINEL = object()
634
 
@@ -869,7 +869,7 @@ async def _send_text(message: Message, text: str, parse_html: bool = True, **kwa
869
  )
870
  if res is None and parse_html:
871
  res = await _tg_call(
872
- message.reply, chunk,
873
  call_timeout=TELEGRAM_REQUEST_TIMEOUT,
874
  parse_mode=None,
875
  **chunk_kwargs,
@@ -885,7 +885,7 @@ async def _send_text(message: Message, text: str, parse_html: bool = True, **kwa
885
  if res is None and parse_html:
886
  await _tg_call(
887
  bot.send_message,
888
- chat_id=message.chat.id, text=chunk,
889
  call_timeout=TELEGRAM_REQUEST_TIMEOUT,
890
  parse_mode=None,
891
  **chunk_kwargs,
@@ -914,7 +914,9 @@ async def _edit_message_quietly(msg: Message | None, text: str, **kwargs: Any) -
914
  if res is not None:
915
  return True
916
  kwargs["parse_mode"] = None
917
- res = await _tg_call(msg.edit_text, text, **kwargs)
 
 
918
  return res is not None
919
  except Exception:
920
  return False
 
401
  # куска — всегда минимум из двух.
402
  FIRST_CHUNK_TIMEOUT_SEC = float(os.getenv("FIRST_CHUNK_TIMEOUT_SEC", "12"))
403
  # Анимация ожидания ("бегущие точки") в плейсхолдере, пока не пришёл первый
404
+ # кусок стрима: первые полсекунды висит статичное "…" (дешевле, чем дёргать
405
+ # API ради мгновенных ответов их анимация вообще не касается), дальше —
406
+ # кадр каждые _DOTS_TICK_SEC. Интервалы подобраны под лимит Telegram (не чаще
407
+ # правки в секунду) с запасом; правки идут только показывать нечего (до
408
+ # первого куска), поэтому с показом текста не конфликтуют.
409
+ _DOTS_START_AFTER_SEC = 0.5
410
  _DOTS_TICK_SEC = 1.2
411
  _DOTS_FRAMES = (".", "..", "…")
412
  # RICH_MESSAGES_ENABLED — отправка финальных ответов через sendRichMessage /
 
628
  # именно ради старых тестов на `bot.X`, но с переездом тестов на прямой импорт
629
  # модуля этот ре-экспорт стал мёртвым (см. аудит техдолга, 26 августа 2026) и
630
  # убран вместе с соответствующим `__all__`.
631
+ from lumen_formatting import _md_to_html, _md_to_rich_html, _split_text_chunks, _strip_markdown
632
 
633
  _PRUNE_SENTINEL = object()
634
 
 
869
  )
870
  if res is None and parse_html:
871
  res = await _tg_call(
872
+ message.reply, _strip_markdown(chunk),
873
  call_timeout=TELEGRAM_REQUEST_TIMEOUT,
874
  parse_mode=None,
875
  **chunk_kwargs,
 
885
  if res is None and parse_html:
886
  await _tg_call(
887
  bot.send_message,
888
+ chat_id=message.chat.id, text=_strip_markdown(chunk),
889
  call_timeout=TELEGRAM_REQUEST_TIMEOUT,
890
  parse_mode=None,
891
  **chunk_kwargs,
 
914
  if res is not None:
915
  return True
916
  kwargs["parse_mode"] = None
917
+ # Последний рубеж голый текст БЕЗ markdown-синтаксиса (см.
918
+ # _strip_markdown): сырые `**` в чате хуже потери жирности.
919
+ res = await _tg_call(msg.edit_text, _strip_markdown(text), **kwargs)
920
  return res is not None
921
  except Exception:
922
  return False
docs/PROMPT_EVAL.md CHANGED
@@ -5,6 +5,21 @@
5
  шкале ниже. Цель: понять, где бот далёк от персоны, и править `system_prompt.py`
6
  точечно, а не вслепую.
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ## Как гонять
9
 
10
  1. Каждый вопрос — отдельным сообщением, по порядку. Контекст между вопросами
 
5
  шкале ниже. Цель: понять, где бот далёк от персоны, и править `system_prompt.py`
6
  точечно, а не вслепую.
7
 
8
+ Файл живёт в проекте постоянно (не удаляется после калибровки): каждый новый
9
+ прогон дописывается в «Журнал прогонов» ниже — так видно прогресс цифрой и
10
+ заметен откат, если правка промпта что-то сломала.
11
+
12
+ Читать удобнее всего в браузере на GitHub (открыть файл в репозитории —
13
+ разметка отрисуется красиво). В Блокноте Windows будет сырой текст с `#` —
14
+ это нормально, содержание то же.
15
+
16
+ ## Журнал прогонов
17
+
18
+ | Дата | Версия промпта | Бот (из 50) | Claude (из 50) | Заметки |
19
+ |---|---|---|---|---|
20
+ | 2026-09-17 | до правок | 42 | ~50 (частичный прогон) | Провалы: Q1 каша, Q8 список, Q17 без уточнения, Q21 выдумка фото, Q22 без оговорки |
21
+ | | | | | |
22
+
23
  ## Как гонять
24
 
25
  1. Каждый вопрос — отдельным сообщением, по порядку. Контекст между вопросами
lumen_formatting.py CHANGED
@@ -291,6 +291,11 @@ def _md_to_html(text: str) -> str:
291
  # баг). Сначала убираем заведомо битые self-closing варианты (напр. "<b/>"),
292
  # затем конвертируем корректные парные теги в markdown-эквивалент — дальше
293
  # они идут по тому же (уже проверенному) конвейеру, что и обычный markdown.
 
 
 
 
 
294
  text = re.sub(r"</?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
295
  text = re.sub(r"<(?:b|strong)>(.*?)</(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
296
  text = re.sub(r"<(?:i|em)>(.*?)</(?:i|em)>", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL)
@@ -396,6 +401,12 @@ def _md_to_html(text: str) -> str:
396
  replacement = f"<code>{inner}</code>"
397
  text = text.replace(key, replacement)
398
 
 
 
 
 
 
 
399
  return text
400
 
401
 
@@ -440,6 +451,9 @@ def _md_to_rich_html(text: str) -> str:
440
  if not text:
441
  return ""
442
 
 
 
 
443
  text = re.sub(r"</?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
444
  text = re.sub(r"<(?:b|strong)>(.*?)</(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
445
  text = re.sub(r"<(?:i|em)>(.*?)</(?:i|em)>", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL)
@@ -544,6 +558,25 @@ def _md_to_rich_html(text: str) -> str:
544
  replacement = f"<code>{inner}</code>"
545
  text = text.replace(key, replacement)
546
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  return text
548
 
549
 
 
291
  # баг). Сначала убираем заведомо битые self-closing варианты (напр. "<b/>"),
292
  # затем конвертируем корректные парные теги в markdown-эквивалент — дальше
293
  # они идут по тому же (уже проверенному) конвейеру, что и обычный markdown.
294
+ # <br> — в сентинел (прод-кейс 17.09.2026: модель пишет "<br>" внутри ячеек
295
+ # таблиц, без обработки долетает до escape и светится буквально). Сентинел
296
+ # невидим для escape и раскрывается в "\n" в самом конце (см. финал ниже);
297
+ # строгий паттерн (<br>, <br/>, <br />) намеренно НЕ ест "<blockquote>".
298
+ text = re.sub(r"<br\s*/?>", "\x00BR\x00", text, flags=re.IGNORECASE)
299
  text = re.sub(r"</?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
300
  text = re.sub(r"<(?:b|strong)>(.*?)</(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
301
  text = re.sub(r"<(?:i|em)>(.*?)</(?:i|em)>", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL)
 
401
  replacement = f"<code>{inner}</code>"
402
  text = text.replace(key, replacement)
403
 
404
+ # Сентинел <br> (см. Phase 0): в обычном HTML-пути — перенос строки.
405
+ # Оговорка: <br> ВНУТРИ код-блоков тоже превратится в перенос (Phase 0
406
+ # идёт до экстракции кода) — тот же класс компромисса, что уже есть у
407
+ # <b>/<i> в коде выше; код-примеры с буквальным <br> редки.
408
+ text = text.replace("\x00BR\x00", "\n")
409
+
410
  return text
411
 
412
 
 
451
  if not text:
452
  return ""
453
 
454
+ # Тот же сентинел <br>, что в _md_to_html выше, но раскрывается в <br/>
455
+ # (валидный рич-тег переноса, в т.ч. внутри ячеек <td>).
456
+ text = re.sub(r"<br\s*/?>", "\x00BR\x00", text, flags=re.IGNORECASE)
457
  text = re.sub(r"</?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
458
  text = re.sub(r"<(?:b|strong)>(.*?)</(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
459
  text = re.sub(r"<(?:i|em)>(.*?)</(?:i|em)>", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL)
 
558
  replacement = f"<code>{inner}</code>"
559
  text = text.replace(key, replacement)
560
 
561
+ text = text.replace("\x00BR\x00", "<br/>")
562
+
563
+ return text
564
+
565
+
566
+ def _strip_markdown(text: str) -> str:
567
+ """Сносит markdown-разметку в голый текст для последнего рубежа отправки
568
+ (plain-text без parse_mode): иначе при отказе HTML/рич в чат уходят сырые
569
+ `**`, backtick-и и `[label](url)` (прод-кейс 17.09.2026 — ответ с `**`
570
+ прилетел как есть). Чистит только синтаксис, слова не трогает."""
571
+ if not text:
572
+ return ""
573
+ text = re.sub(r"```[a-zA-Z0-9]*\n(.*?)\n```", r"\1", text, flags=re.DOTALL)
574
+ text = re.sub(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", r"\1", text)
575
+ text = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text, flags=re.DOTALL)
576
+ text = re.sub(r"(\*|_)(.*?)\1", r"\2", text)
577
+ text = re.sub(r"~~(.*?)~~", r"\1", text)
578
+ text = re.sub(r"`([^`\n]+)`", r"\1", text)
579
+ text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
580
  return text
581
 
582
 
lumen_router_config.py CHANGED
@@ -547,6 +547,11 @@ def _gemini_route(models: list[str]) -> list[tuple[str, str]]:
547
  # ОБНОВЛЕНО вечером 17.09.2026 по первому же прод-трафику: inkling-small
548
  # исключён (только agent harnesses, см. _OR_MODEL_HEALTH), nex-n2.5-mini
549
  # подтверждён живьём (успешный ответ в проде) и повышен на его место.
 
 
 
 
 
550
  _OR_LIGHT_ORDER: list[str] = [
551
  "nvidia/nemotron-3.5-lightning:free",
552
  "nex-agi/nex-n2.5-mini:free",
@@ -692,7 +697,9 @@ _HEAVY_QUERY_RE = re.compile(
692
  r"|найди\s+(ошибк\w*|баг\w*)|объясни\s+(код|ошибк\w*)"
693
  r"|напиши\s+(эссе|статью|доклад|реферат|сочинение|резюме|cv)\b"
694
  r"|проанализируй\w*|разбер(и|ём)\s+подробно|объясни\s+подробно"
695
- r"|сравни\s+.{0,40}(и|с)\s+|докажи\b|доказательство"
 
 
696
  r"|реши\s+(задач\w*|уравнени\w*|систем\w*)"
697
  r"|составь\s+(план|таблиц\w*|список\s+из)"
698
  r"|многошагов\w*|пошагов\w*\s+(инструкц\w*|план\w*)"
@@ -720,6 +727,11 @@ _FRESHNESS_QUERY_RE = re.compile(
720
  r"|новост\w*|курс\s+(валют|доллара|евро|рубл\w*)|погод\w*|прогноз\w*"
721
  r"|расписани\w*|афиш\w*"
722
  r"|цена\w*|стоимост\w*|сколько\s+стоит"
 
 
 
 
 
723
  r"|кто\s+(сейчас|является|президент|премьер|глава|ceo|мэр)"
724
  r"|результат\w*\s+(матч\w*|игр\w*|выбор\w*)"
725
  r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)"
 
547
  # ОБНОВЛЕНО вечером 17.09.2026 по первому же прод-трафику: inkling-small
548
  # исключён (только agent harnesses, см. _OR_MODEL_HEALTH), nex-n2.5-mini
549
  # подтверждён живьём (успешный ответ в проде) и повышен на его место.
550
+ # НАБЛЮДЕНИЕ (там же): один ответ nex-mini содержал вкрапления чужих языков
551
+ # ("сопプロцессоры", испанское "cuello", "Обе.platforms") — детектор
552
+ # [mush-suspect] поймал только явное смешение внутри слов, замены целых слов
553
+ # не ловит никто. Один инцидент — не хроника nano-9b: наблюдаем, при повторе —
554
+ # понижать (см. прецедент super-120b ниже).
555
  _OR_LIGHT_ORDER: list[str] = [
556
  "nvidia/nemotron-3.5-lightning:free",
557
  "nex-agi/nex-n2.5-mini:free",
 
697
  r"|найди\s+(ошибк\w*|баг\w*)|объясни\s+(код|ошибк\w*)"
698
  r"|напиши\s+(эссе|статью|доклад|реферат|сочинение|резюме|cv)\b"
699
  r"|проанализируй\w*|разбер(и|ём)\s+подробно|объясни\s+подробно"
700
+ # Голое "сравни( конкурентов)" — тоже сравнение (прод-кейс 17.09.2026),
701
+ # вариант с "и/с" выше оставлен для явных пар.
702
+ r"|сравни\b|докажи\b|доказательство"
703
  r"|реши\s+(задач\w*|уравнени\w*|систем\w*)"
704
  r"|составь\s+(план|таблиц\w*|список\s+из)"
705
  r"|многошагов\w*|пошагов\w*\s+(инструкц\w*|план\w*)"
 
727
  r"|новост\w*|курс\s+(валют|доллара|евро|рубл\w*)|погод\w*|прогноз\w*"
728
  r"|расписани\w*|афиш\w*"
729
  r"|цена\w*|стоимост\w*|сколько\s+стоит"
730
+ # Советы по покупке (прод-кейс 17.09.2026: "какой проц лучше всего брать" —
731
+ # цены, наличие и новинки меняются постоянно, ответ из памяти протухает).
732
+ # Ложные срабатывания дешёвые (Gemini и так отвечает на всё подряд).
733
+ r"|лучше\s+(всего\s+)?(брать|взять|купить|выбрать)"
734
+ r"|(брать|взять|купить|выбрать)\s+лучше"
735
  r"|кто\s+(сейчас|является|президент|премьер|глава|ceo|мэр)"
736
  r"|результат\w*\s+(матч\w*|игр\w*|выбор\w*)"
737
  r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)"
test_lumen_formatting.py CHANGED
@@ -449,6 +449,28 @@ def test_rich_headings_tolerate_indent_and_deep_levels():
449
  assert lumen_formatting._md_to_rich_html("#### Глубокий") == "<h4>Глубокий</h4>"
450
 
451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  def test_rich_prices_are_not_treated_as_math():
453
  # "$50 до $100" — контент с пробелом у границы, не формула. Одиночный знак
454
  # без пары — тоже текст.
 
449
  assert lumen_formatting._md_to_rich_html("#### Глубокий") == "<h4>Глубокий</h4>"
450
 
451
 
452
+ def test_br_tag_becomes_line_break_in_both_paths():
453
+ # Прод-кейс 17.09.2026: модель пишет "<br>" внутри ячеек таблиц — без
454
+ # обработки долетает до escape и светится буквально ("...сгор.<br>Pro Max").
455
+ assert lumen_formatting._md_to_html("a<br>b") == "a\nb"
456
+ assert lumen_formatting._md_to_html("a<br/>b") == "a\nb"
457
+ assert lumen_formatting._md_to_rich_html("| H | K |\n|---|---|\n| a<br>b | 2 |") == (
458
+ "<table bordered><tr><th>H</th><th>K</th></tr><tr><td>a<br/>b</td><td>2</td></tr></table>"
459
+ )
460
+ # "<blockquote>" не должен съедаться строгим паттерном <br>.
461
+ assert "blockquote" in lumen_formatting._md_to_html("<blockquote>ц</blockquote>")
462
+
463
+
464
+ def test_strip_markdown_removes_syntax_keeping_words():
465
+ assert lumen_formatting._strip_markdown("**жирный** и *курсив*") == "жирный и курсив"
466
+ assert lumen_formatting._strip_markdown("код `x=1` тут") == "код x=1 тут"
467
+ assert lumen_formatting._strip_markdown("[текст](https://example.com)") == "текст"
468
+ assert lumen_formatting._strip_markdown("## Заголовок") == "Заголовок"
469
+ assert lumen_formatting._strip_markdown("~~чист~~") == "чист"
470
+ assert lumen_formatting._strip_markdown("") == ""
471
+ assert lumen_formatting._strip_markdown("обычный текст 5 < 10") == "обычный текст 5 < 10"
472
+
473
+
474
  def test_rich_prices_are_not_treated_as_math():
475
  # "$50 до $100" — контент с пробелом у границы, не формула. Одиночный знак
476
  # без пары — тоже текст.
test_lumen_router_config.py CHANGED
@@ -75,6 +75,23 @@ def test_looks_like_freshness_query_detects_schedule_and_forecast():
75
  assert lumen_router_config._looks_like_freshness_query("любимый цвет") is False
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def test_build_route_youtube_link_forces_gemini_only():
79
  route = lumen_router_config._build_route(needs_youtube=True, needs_website=False, media_mime=None, is_heavy=False, needs_freshness=False)
80
  assert all(p == "gemini" for p, _ in route)
 
75
  assert lumen_router_config._looks_like_freshness_query("любимый цвет") is False
76
 
77
 
78
+ def test_looks_like_freshness_query_detects_buying_advice():
79
+ # Прод-кейс 17.09.2026: "какой проц лучше всего брать под ртх 5060" ушёл
80
+ # лёгким маршрутом в слабую модель — та ответила протухшими знаниями
81
+ # (RTX 5060 "ещё не объявлена") вместо поиска. Советы по покупке всегда
82
+ # про цены/наличие/новинки — им нужен живой поиск.
83
+ assert lumen_router_config._looks_like_freshness_query("какой проц лучше всего брать под ртх 5060") is True
84
+ assert lumen_router_config._looks_like_freshness_query("что лучше купить для игр") is True
85
+ assert lumen_router_config._looks_like_freshness_query("какой телефон лучше взять") is True
86
+
87
+
88
+ def test_looks_like_heavy_query_detects_bare_compare():
89
+ # Прод-кейс 17.09.2026: "сравни конкурентов" без "и/с" между объектами
90
+ # пролетал мимо тяжёлой ветки — сравнение ехало на слабую модель.
91
+ assert lumen_router_config._looks_like_heavy_query("сравни конкурентов") is True
92
+ assert lumen_router_config._looks_like_heavy_query("сравни iphone и samsung") is True
93
+
94
+
95
  def test_build_route_youtube_link_forces_gemini_only():
96
  route = lumen_router_config._build_route(needs_youtube=True, needs_website=False, media_mime=None, is_heavy=False, needs_freshness=False)
97
  assert all(p == "gemini" for p, _ in route)