SilverElixir commited on
Commit
f40e9a4
·
1 Parent(s): e9b6d6a

Finish audit leftovers: speed_key dedup, truncate in formatting, honest mime suffixes, filename sanitize, tiger boundary, dead image data, deterministic timing tests, HEALTHCHECK, dependabot (AUD-B-003, AUD-E-005, AUD-E-006, AUD-K-001, AUD-M-001, AUD-M-004)

Browse files
.dockerignore CHANGED
@@ -31,5 +31,6 @@ chat_state.json
31
  chat_state.tmp
32
  global_quota.json
33
  global_quota.tmp
 
34
 
35
  .DS_Store
 
31
  chat_state.tmp
32
  global_quota.json
33
  global_quota.tmp
34
+ export_state.json
35
 
36
  .DS_Store
.github/dependabot.yml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+ updates:
3
+ # CI-гейт автодеплоит в прод ("зелёный CI = прод"), а экшены закреплены
4
+ # мажор-тегами (v6/v7) — Dependabot открывает PR на новые релизы экшенов,
5
+ # чтобы дрейф версий был видимым решением, а не тихим (AUD-M-001).
6
+ - package-ecosystem: "github-actions"
7
+ directory: "/"
8
+ schedule:
9
+ interval: "weekly"
10
+ - package-ecosystem: "pip"
11
+ directory: "/"
12
+ schedule:
13
+ interval: "weekly"
Dockerfile CHANGED
@@ -45,4 +45,9 @@ COPY . /app
45
 
46
  EXPOSE 7860
47
 
 
 
 
 
 
48
  CMD ["python", "-u", "bot.py"]
 
45
 
46
  EXPOSE 7860
47
 
48
+ # Дешёвая проба живости — in-memory "/" без сетевых вызовов (см. healthcheck в
49
+ # bot.py). Отдельного curl в образе нет — хватает stdlib-urllib.
50
+ HEALTHCHECK --interval=60s --timeout=5s --start-period=60s --retries=3 \
51
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/', timeout=4)"
52
+
53
  CMD ["python", "-u", "bot.py"]
bot.py CHANGED
@@ -648,7 +648,7 @@ async def _close_sessions() -> None:
648
  # именно ради старых тестов на `bot.X`, но с переездом тестов на прямой импорт
649
  # модуля этот ре-экспорт стал мёртвым (см. аудит техдолга, 26 августа 2026) и
650
  # убран вместе с соответствующим `__all__`.
651
- from lumen_formatting import _md_to_html, _md_to_rich_html, _split_text_chunks, _strip_markdown
652
 
653
  _PRUNE_SENTINEL = object()
654
 
@@ -789,23 +789,6 @@ def is_guest_message(message: Message | dict) -> bool:
789
  return bool(message.get("guest_query_id"))
790
  return bool(getattr(message, "guest_query_id", None))
791
 
792
- def _truncate_html_to_fit(md_text: str, limit: int) -> str:
793
- """HTML по границе исходника, а не по границе тегов: резать готовый HTML
794
- по codepoint можно угодить в середину <b>/ссылки — Telegram ответит
795
- "can't parse entities" (AUD-J-002). Бинарным поиском ищем самый длинный
796
- префикс исходника, чей HTML влезает в лимит."""
797
- full = _md_to_html(md_text)
798
- if len(full) <= limit:
799
- return full
800
- lo, hi = 0, len(md_text)
801
- while lo < hi:
802
- mid = (lo + hi + 1) // 2
803
- if len(_md_to_html(md_text[:mid])) <= limit - 1:
804
- lo = mid
805
- else:
806
- hi = mid - 1
807
- return _md_to_html(md_text[:lo])[:limit - 1] + "…"
808
-
809
  async def _answer_guest_text(message: Message, text: str) -> None:
810
  qid = getattr(message, "guest_query_id", None)
811
  if not qid:
@@ -2633,9 +2616,12 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
2633
  except Exception as tag_err:
2634
  log.warning("[tiktok] failed to write embedded tags to MP3: %s", tag_err)
2635
 
 
 
 
2636
  await bot.send_audio(
2637
- chat_id=message.chat.id,
2638
- audio=BufferedInputFile(tagged_music_bytes, filename=f"{cleaned_title[:60]}.mp3"),
2639
  title=cleaned_title,
2640
  performer=performer_name,
2641
  thumbnail=thumbnail_file,
 
648
  # именно ради старых тестов на `bot.X`, но с переездом тестов на прямой импорт
649
  # модуля этот ре-экспорт стал мёртвым (см. аудит техдолга, 26 августа 2026) и
650
  # убран вместе с соответствующим `__all__`.
651
+ from lumen_formatting import _md_to_html, _md_to_rich_html, _split_text_chunks, _strip_markdown, _truncate_html_to_fit
652
 
653
  _PRUNE_SENTINEL = object()
654
 
 
789
  return bool(message.get("guest_query_id"))
790
  return bool(getattr(message, "guest_query_id", None))
791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
792
  async def _answer_guest_text(message: Message, text: str) -> None:
793
  qid = getattr(message, "guest_query_id", None)
794
  if not qid:
 
2616
  except Exception as tag_err:
2617
  log.warning("[tiktok] failed to write embedded tags to MP3: %s", tag_err)
2618
 
2619
+ # Слэш и управляющие из чужого названия — в "_" (AUD-E-005),
2620
+ # иначе multipart-имя файла битое.
2621
+ safe_title = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", cleaned_title[:60]).strip() or "track"
2622
  await bot.send_audio(
2623
+ chat_id=message.chat.id,
2624
+ audio=BufferedInputFile(tagged_music_bytes, filename=f"{safe_title}.mp3"),
2625
  title=cleaned_title,
2626
  performer=performer_name,
2627
  thumbnail=thumbnail_file,
lumen_formatting.py CHANGED
@@ -609,3 +609,22 @@ def _split_text_chunks(text: str, max_len: int = 4096) -> list[str]:
609
  chunks.append(remaining)
610
  return chunks
611
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  chunks.append(remaining)
610
  return chunks
611
 
612
+
613
+ def _truncate_html_to_fit(md_text: str, limit: int) -> str:
614
+ """HTML по границе исходника, а не по границе тегов: резать готовый HTML
615
+ по codepoint можно угодить в середину <b>/ссылки — Telegram ответит
616
+ "can't parse entities" (AUD-J-002). Бинарным поиском ищем самый длинный
617
+ префикс исходника, чей HTML влезает в лимит. Вынесено из bot.py (гостевые
618
+ ответы — одиночный InlineQueryResultArticle без фолбэка на plain-текст)."""
619
+ full = _md_to_html(md_text)
620
+ if len(full) <= limit:
621
+ return full
622
+ lo, hi = 0, len(md_text)
623
+ while lo < hi:
624
+ mid = (lo + hi + 1) // 2
625
+ if len(_md_to_html(md_text[:mid])) <= limit - 1:
626
+ lo = mid
627
+ else:
628
+ hi = mid - 1
629
+ return _md_to_html(md_text[:lo])[:limit - 1] + "…"
630
+
lumen_images.py CHANGED
@@ -40,26 +40,11 @@ import aiohttp
40
  DEFAULT_POLLINATIONS_IMAGE_MODEL = os.getenv("POLLINATIONS_IMAGE_MODEL", "flux").strip()
41
 
42
  POLLINATIONS_IMAGE_MODELS: dict[str, dict[str, Any]] = {
43
- "flux": {
44
- "name": "FLUX Pro",
45
- "desc": "Высококачественный FLUX. Фотореализм, точное следование промпту, богатая детализация.",
46
- },
47
- "flux-realism": {
48
- "name": "FLUX Realism",
49
- "desc": "FLUX с акцентом на гиперреализм — детализированные текстуры, естественное освещение, кинематографичность.",
50
- },
51
- "flux-anime": {
52
- "name": "FLUX Anime",
53
- "desc": "FLUX для аниме и иллюстраций — характерные пропорции, яркие цвета, стилизация под японскую графику.",
54
- },
55
- "turbo": {
56
- "name": "Turbo",
57
- "desc": "Быстрая дистиллированная модель. Результат за несколько секунд — для черновиков и быстрых итераций.",
58
- },
59
- "dreamshaper": {
60
- "name": "DreamShaper",
61
- "desc": "Художественная модель для фэнтези, концепт-арта и стилизованных иллюстраций.",
62
- },
63
  }
64
 
65
  # ── Автоматический выбор модели генерации по содержимому промпта ──
@@ -135,8 +120,3 @@ async def _pollinations_text_to_image(session: aiohttp.ClientSession, model_id:
135
  if model_id not in POLLINATIONS_IMAGE_MODELS:
136
  raise ValueError(f"Неизвестная модель генерации изображений: {model_id}")
137
  return await _pollinations_generate(session, model_id, prompt)
138
-
139
-
140
- def _image_model_label(model_id: str) -> str:
141
- meta = POLLINATIONS_IMAGE_MODELS.get(model_id, {})
142
- return meta.get("name", model_id)
 
40
  DEFAULT_POLLINATIONS_IMAGE_MODEL = os.getenv("POLLINATIONS_IMAGE_MODEL", "flux").strip()
41
 
42
  POLLINATIONS_IMAGE_MODELS: dict[str, dict[str, Any]] = {
43
+ "flux": {"name": "FLUX Pro"},
44
+ "flux-realism": {"name": "FLUX Realism"},
45
+ "flux-anime": {"name": "FLUX Anime"},
46
+ "turbo": {"name": "Turbo"},
47
+ "dreamshaper": {"name": "DreamShaper"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  }
49
 
50
  # ── Автоматический выбор модели генерации по содержимому промпта ──
 
120
  if model_id not in POLLINATIONS_IMAGE_MODELS:
121
  raise ValueError(f"Неизвестная модель генерации изображений: {model_id}")
122
  return await _pollinations_generate(session, model_id, prompt)
 
 
 
 
 
lumen_media.py CHANGED
@@ -139,10 +139,21 @@ def _mime_suffix(mime: str, filename: str = "") -> str:
139
  if m.startswith("image/"):
140
  sub = m.split("/", 1)[1]
141
  return {"jpeg": ".jpg", "jpg": ".jpg", "png": ".png", "gif": ".gif", "webp": ".webp"}.get(sub, f".{sub}")
 
 
142
  if m.startswith("audio/"):
143
- return ".mp3"
 
 
 
 
 
144
  if m.startswith("video/"):
145
- return ".mp4"
 
 
 
 
146
  return ".bin"
147
 
148
  def _msg_media_source(message: Any) -> Any | None:
 
139
  if m.startswith("image/"):
140
  sub = m.split("/", 1)[1]
141
  return {"jpeg": ".jpg", "jpg": ".jpg", "png": ".png", "gif": ".gif", "webp": ".webp"}.get(sub, f".{sub}")
142
+ # Честная карта вместо ".mp3 для любого audio" (AUD-E-005): расширение врёт
143
+ # редко, но метка временному файлу должна соответствовать содержимому.
144
  if m.startswith("audio/"):
145
+ sub = m.split("/", 1)[1].split(";")[0].strip()
146
+ return {
147
+ "mpeg": ".mp3", "mp3": ".mp3", "ogg": ".ogg", "wav": ".wav",
148
+ "x-wav": ".wav", "webm": ".webm", "mp4": ".m4a", "x-m4a": ".m4a",
149
+ "aac": ".aac", "flac": ".flac", "opus": ".opus",
150
+ }.get(sub, f".{sub}")
151
  if m.startswith("video/"):
152
+ sub = m.split("/", 1)[1].split(";")[0].strip()
153
+ return {
154
+ "mp4": ".mp4", "quicktime": ".mov", "x-msvideo": ".avi",
155
+ "x-matroska": ".mkv", "webm": ".webm", "mpeg": ".mpg",
156
+ }.get(sub, f".{sub}")
157
  return ".bin"
158
 
159
  def _msg_media_source(message: Any) -> Any | None:
lumen_message_parse.py CHANGED
@@ -274,12 +274,13 @@ def match_pick_request(text_lower: str) -> str | None:
274
  return None
275
  for topic, scenario in _PICK_TOPICS.items():
276
  # Латиницу — строго по границам слов ("notebook" — не книги,
277
- # "shower" — не сериалы); кириллицу — подстрокой, как раньше
278
- # (там префиксные корни вида "музык"/"книг" так и задуманы).
 
279
  if topic.isascii():
280
  if re.search(r"\b" + re.escape(topic) + r"\b", text):
281
  return scenario
282
- elif topic in text:
283
  return scenario
284
  return None
285
 
 
274
  return None
275
  for topic, scenario in _PICK_TOPICS.items():
276
  # Латиницу — строго по границам слов ("notebook" — не книги,
277
+ # "shower" — не сериалы); кириллицу — по началу слова: корни вида
278
+ # "музык"/"книг" так и задуманы префиксными, но "тигр" — не игры
279
+ # (AUD-E-006). \w в Python юникодный, кириллицу покрывает.
280
  if topic.isascii():
281
  if re.search(r"\b" + re.escape(topic) + r"\b", text):
282
  return scenario
283
+ elif re.search(r"(?<!\w)" + re.escape(topic), text):
284
  return scenario
285
  return None
286
 
lumen_model_speed.py CHANGED
@@ -42,6 +42,10 @@ lumen_model_speed.py — самокалибрующаяся оценка зад
42
 
43
  from __future__ import annotations
44
 
 
 
 
 
45
  # Насколько сильно один новый замер сдвигает EMA — тот же компромисс
46
  # "стабильность против скорости подстройки", что и в lumen_typing_pace.py.
47
  _EMA_ALPHA = 0.3
@@ -66,12 +70,6 @@ _TTF_ABANDON_MULT = 2.5
66
  _latency_ema: dict[str, tuple[float, float]] = {}
67
 
68
 
69
- def speed_key(provider: str, model_id: str) -> str:
70
- """Единый ключ — тот же формат пары (provider, model_id), что в
71
- lumen_typing_pace.speed_key и GLOBAL_QUOTA."""
72
- return f"{provider}:{model_id}"
73
-
74
-
75
  def expected_ttf_sec(key: str) -> float:
76
  """Ожидаемое время до первого куска — EMA или приор для новой модели."""
77
  ttf = _latency_ema.get(key, (_DEFAULT_TTF_SEC, _DEFAULT_TOTAL_SEC))[0]
 
42
 
43
  from __future__ import annotations
44
 
45
+ # Единый ключ provider:model_id живёт в lumen_typing_pace (там каноническое
46
+ # определение) — здесь реэкспорт, чтобы не плодить два одинаковых форматтера.
47
+ from lumen_typing_pace import speed_key
48
+
49
  # Насколько сильно один новый замер сдвигает EMA — тот же компромисс
50
  # "стабильность против скорости подстройки", что и в lumen_typing_pace.py.
51
  _EMA_ALPHA = 0.3
 
70
  _latency_ema: dict[str, tuple[float, float]] = {}
71
 
72
 
 
 
 
 
 
 
73
  def expected_ttf_sec(key: str) -> float:
74
  """Ожидаемое время до первого куска — EMA или приор для новой модели."""
75
  ttf = _latency_ema.get(key, (_DEFAULT_TTF_SEC, _DEFAULT_TOTAL_SEC))[0]
test_bot.py CHANGED
@@ -137,6 +137,16 @@ def test_sanitize_mime_type_audio_ogg_passthrough():
137
  assert bot._sanitize_mime_type(None, "audio/ogg") == "audio/ogg"
138
 
139
 
 
 
 
 
 
 
 
 
 
 
140
  def test_ensure_prompt_text_gif_gets_animation_prompt_not_generic_image():
141
  assert "анимации" in bot._ensure_prompt_text(None, "image/gif")
142
  assert "анимации" in bot._ensure_prompt_text(" ", "image/GIF")
@@ -1900,6 +1910,34 @@ def _run_send_tiktok_music(media_data: dict, language_code: str | None = "ru"):
1900
  return captured.get("title"), captured.get("artist")
1901
 
1902
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1903
  def test_send_tiktok_music_truly_generic_original_sound_uses_localized_label():
1904
  # raw_music_title буквально "original sound" без остатка (TikTok в этом случае
1905
  # обычно добавляет юзернейм автора видео тем же куском — здесь его просто нет
@@ -3213,7 +3251,7 @@ def test_ask_gemini_falls_back_to_next_model_on_timeout():
3213
  def fake_generate_content(*, model, contents, config=None):
3214
  calls.append(model)
3215
  if model == "gemini-3.6-flash":
3216
- time.sleep(0.2) # дольше урезанного ROUTE_MODEL_TIMEOUT_SEC ниже
3217
  return _FakeGeminiResponse(text="Ответ от второй модели")
3218
 
3219
  fake_client = MagicMock()
@@ -3949,17 +3987,6 @@ def test_allowed_updates_contains_only_real_telegram_types():
3949
  assert "message" in bot.ALLOWED_UPDATES
3950
 
3951
 
3952
- def test_truncate_html_to_fit_keeps_short_text_and_cuts_at_source_boundary():
3953
- # Регрессия AUD-J-002: рез готового HTML рвал теги ("can't parse entities").
3954
- short = bot._truncate_html_to_fit("привет", bot.TG_MAX_LEN)
3955
- assert short == bot._md_to_html("привет")
3956
- long_md = "**" + "x" * 5000 + "**"
3957
- cut = bot._truncate_html_to_fit(long_md, bot.TG_MAX_LEN)
3958
- assert len(cut) <= bot.TG_MAX_LEN
3959
- assert cut.endswith("…")
3960
- assert cut.count("<b>") == cut.count("</b>")
3961
-
3962
-
3963
  # ─────────────────── ADMIN_SECRET_SEED — независимая ротация секретов ───────────────────
3964
 
3965
  def test_admin_secrets_are_independent_of_bot_token_when_seed_set():
@@ -4384,6 +4411,7 @@ def test_handle_tiktok_slideshow_video_probing_respects_concurrency_cap():
4384
  current_concurrent = 0
4385
  max_concurrent_seen = 0
4386
  lock = asyncio.Lock()
 
4387
 
4388
  async def fake_get_http_session():
4389
  return _FakeTikTokSession(tikwm_json)
@@ -4399,7 +4427,11 @@ def test_handle_tiktok_slideshow_video_probing_respects_concurrency_cap():
4399
  async with lock:
4400
  current_concurrent += 1
4401
  max_concurrent_seen = max(max_concurrent_seen, current_concurrent)
4402
- await asyncio.sleep(0.05) # достаточно, чтобы вызовы реально пересеклись во времени
 
 
 
 
4403
  async with lock:
4404
  current_concurrent -= 1
4405
  return 0, 0, 0, None
@@ -4453,6 +4485,7 @@ def test_handle_tiktok_slideshow_download_respects_concurrency_cap():
4453
  current_concurrent = 0
4454
  max_concurrent_seen = 0
4455
  lock = asyncio.Lock()
 
4456
 
4457
  async def fake_get_http_session():
4458
  return _FakeTikTokSession(tikwm_json)
@@ -4465,7 +4498,10 @@ def test_handle_tiktok_slideshow_download_respects_concurrency_cap():
4465
  async with lock:
4466
  current_concurrent += 1
4467
  max_concurrent_seen = max(max_concurrent_seen, current_concurrent)
4468
- await asyncio.sleep(0.05) # достаточно, чтобы вызовы реально пересеклись во времени
 
 
 
4469
  async with lock:
4470
  current_concurrent -= 1
4471
  return b"\xff\xd8\xff\xe0fake jpeg bytes" # не ftyp -> обычное фото, не видео-слайд
@@ -5320,6 +5356,9 @@ def test_match_pick_request_rejects_detailed_or_unrelated():
5320
  assert bot.match_pick_request("") is None
5321
  # Латиница — только по границам слов: "notebook" — не книги.
5322
  assert bot.match_pick_request("recommend a notebook") is None
 
 
 
5323
 
5324
 
5325
  def test_pick_question_sent_instead_of_ai_route(rate_guard_setup, monkeypatch):
 
137
  assert bot._sanitize_mime_type(None, "audio/ogg") == "audio/ogg"
138
 
139
 
140
+ def test_mime_suffix_maps_audio_video_subtypes_honestly():
141
+ # Регрессия AUD-E-005: было ".mp3 для любого audio" — расширение врало.
142
+ assert bot._mime_suffix("audio/ogg", "") == ".ogg"
143
+ assert bot._mime_suffix("audio/mpeg", "") == ".mp3"
144
+ assert bot._mime_suffix("audio/wav", "") == ".wav"
145
+ assert bot._mime_suffix("video/quicktime", "") == ".mov"
146
+ assert bot._mime_suffix("video/mp4", "") == ".mp4"
147
+ assert bot._mime_suffix("image/jpeg", "") == ".jpg"
148
+
149
+
150
  def test_ensure_prompt_text_gif_gets_animation_prompt_not_generic_image():
151
  assert "анимации" in bot._ensure_prompt_text(None, "image/gif")
152
  assert "анимации" in bot._ensure_prompt_text(" ", "image/GIF")
 
1910
  return captured.get("title"), captured.get("artist")
1911
 
1912
 
1913
+ def test_send_tiktok_music_filename_sanitizes_slashes_and_control_chars():
1914
+ # Регрессия AUD-E-005: слэш/контролы из чужого названия в multipart-имени.
1915
+ incoming = _FakeIncomingMessage(999602)
1916
+ incoming.message_id = 12345
1917
+ incoming.from_user = SimpleNamespace(language_code="ru")
1918
+ media_data = {
1919
+ "music": "https://example.com/sound.mp3",
1920
+ "music_info": {"title": "a/b\x01c", "author": "X", "cover": ""},
1921
+ "author": {"nickname": "N", "unique_id": "n"},
1922
+ }
1923
+
1924
+ async def fake_download(session, url, headers=None):
1925
+ return b"fake-bytes"
1926
+
1927
+ fake_bot = _FakeAudioBot()
1928
+ original_download, original_bot = bot._download_url_bin, bot.bot
1929
+ bot._download_url_bin = fake_download
1930
+ bot.bot = fake_bot
1931
+ try:
1932
+ asyncio.run(bot._send_tiktok_music(None, media_data, incoming, "N", {}))
1933
+ finally:
1934
+ bot._download_url_bin = original_download
1935
+ bot.bot = original_bot
1936
+ filename = fake_bot.sent_audio["audio"].filename
1937
+ assert "/" not in filename and "\\" not in filename and "\x01" not in filename
1938
+ assert filename.endswith(".mp3")
1939
+
1940
+
1941
  def test_send_tiktok_music_truly_generic_original_sound_uses_localized_label():
1942
  # raw_music_title буквально "original sound" без остатка (TikTok в этом случае
1943
  # обычно добавляет юзернейм автора видео тем же куском — здесь его просто нет
 
3251
  def fake_generate_content(*, model, contents, config=None):
3252
  calls.append(model)
3253
  if model == "gemini-3.6-flash":
3254
+ time.sleep(0.5) # 10-кратный запас над ROUTE_MODEL_TIMEOUT_SEC ниже
3255
  return _FakeGeminiResponse(text="Ответ от второй модели")
3256
 
3257
  fake_client = MagicMock()
 
3987
  assert "message" in bot.ALLOWED_UPDATES
3988
 
3989
 
 
 
 
 
 
 
 
 
 
 
 
3990
  # ─────────────────── ADMIN_SECRET_SEED — независимая ротация секретов ───────────────────
3991
 
3992
  def test_admin_secrets_are_independent_of_bot_token_when_seed_set():
 
4411
  current_concurrent = 0
4412
  max_concurrent_seen = 0
4413
  lock = asyncio.Lock()
4414
+ overlapped = asyncio.Event()
4415
 
4416
  async def fake_get_http_session():
4417
  return _FakeTikTokSession(tikwm_json)
 
4427
  async with lock:
4428
  current_concurrent += 1
4429
  max_concurrent_seen = max(max_concurrent_seen, current_concurrent)
4430
+ if current_concurrent >= 2:
4431
+ overlapped.set()
4432
+ # Детерминированный барьер вместо sleep: дальше идёт только тот, кто
4433
+ # реально пересёкся с соседом; последовательный регресс упрётся в таймаут.
4434
+ await asyncio.wait_for(overlapped.wait(), timeout=2)
4435
  async with lock:
4436
  current_concurrent -= 1
4437
  return 0, 0, 0, None
 
4485
  current_concurrent = 0
4486
  max_concurrent_seen = 0
4487
  lock = asyncio.Lock()
4488
+ overlapped = asyncio.Event()
4489
 
4490
  async def fake_get_http_session():
4491
  return _FakeTikTokSession(tikwm_json)
 
4498
  async with lock:
4499
  current_concurrent += 1
4500
  max_concurrent_seen = max(max_concurrent_seen, current_concurrent)
4501
+ if current_concurrent >= 2:
4502
+ overlapped.set()
4503
+ # Детерминированный барьер вместо sleep (см. комментарий в probe-тесте выше).
4504
+ await asyncio.wait_for(overlapped.wait(), timeout=2)
4505
  async with lock:
4506
  current_concurrent -= 1
4507
  return b"\xff\xd8\xff\xe0fake jpeg bytes" # не ftyp -> обычное фото, не видео-слайд
 
5356
  assert bot.match_pick_request("") is None
5357
  # Латиница — только по границам слов: "notebook" — не книги.
5358
  assert bot.match_pick_request("recommend a notebook") is None
5359
+ # Кириллица — по началу слова: "тигр" — не игры (AUD-E-006), "игру" — игры.
5360
+ assert bot.match_pick_request("посоветуй тигра") is None
5361
+ assert bot.match_pick_request("придумай игру для компании") == "games"
5362
 
5363
 
5364
  def test_pick_question_sent_instead_of_ai_route(rate_guard_setup, monkeypatch):
test_lumen_formatting.py CHANGED
@@ -18,6 +18,20 @@ import lumen_formatting
18
 
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  # ─────────────────────────── _md_to_html ───────────────────────────
22
 
23
  def test_md_to_html_empty_string():
 
18
 
19
 
20
 
21
+ # ─────────────────────────── _truncate_html_to_fit ───────────────────────────
22
+
23
+ def test_truncate_html_to_fit_keeps_short_text_and_cuts_at_source_boundary():
24
+ # Регрессия AUD-J-002: рез готового HTML рвал теги ("can't parse entities").
25
+ # Переехала из test_bot.py вслед за функцией (TG_MAX_LEN=4096 — лимит Telegram).
26
+ short = lumen_formatting._truncate_html_to_fit("привет", 4096)
27
+ assert short == lumen_formatting._md_to_html("привет")
28
+ long_md = "**" + "x" * 5000 + "**"
29
+ cut = lumen_formatting._truncate_html_to_fit(long_md, 4096)
30
+ assert len(cut) <= 4096
31
+ assert cut.endswith("…")
32
+ assert cut.count("<b>") == cut.count("</b>")
33
+
34
+
35
  # ─────────────────────────── _md_to_html ───────────────────────────
36
 
37
  def test_md_to_html_empty_string():