SilverElixir commited on
Commit
453b05f
·
1 Parent(s): bae36ef

Fix ttf EMA pollution, quota log, explicit url_context, dynamic freshness years, transcribe cap (A2-4, A2-10..A2-13)

Browse files
lumen_model_speed.py CHANGED
@@ -42,14 +42,17 @@ def expected_total_sec(key: str) -> float:
42
 
43
 
44
  def record_response(key: str, *, total_sec: float, ttf_sec: float | None = None) -> None:
45
- """Учесть один УСПЕШНЫЙ ответ. Без ttf_sec подставляется total_sec, неположительные замеры игнорируются."""
 
 
46
  if total_sec <= 0:
47
  return
 
48
  if ttf_sec is None or ttf_sec <= 0:
49
- ttf_sec = total_sec
50
- obs_ttf = max(_MIN_TTF_SEC, min(_MAX_TTF_SEC, ttf_sec))
 
51
  obs_total = max(_MIN_TOTAL_SEC, min(_MAX_TOTAL_SEC, total_sec))
52
- prev = _latency_ema.get(key)
53
  if prev is None:
54
  _latency_ema[key] = (obs_ttf, obs_total)
55
  else:
 
42
 
43
 
44
  def record_response(key: str, *, total_sec: float, ttf_sec: float | None = None) -> None:
45
+ """Учесть один УСПЕШНЫЙ ответ. Без ttf_sec (нестриминговый ответ первого куска
46
+ не видел) ttf-EMA не трогаем: подстановка total завышала first_chunk_limit,
47
+ и зависшая голова держалась дольше. Неположительные замеры игнорируются."""
48
  if total_sec <= 0:
49
  return
50
+ prev = _latency_ema.get(key)
51
  if ttf_sec is None or ttf_sec <= 0:
52
+ obs_ttf = prev[0] if prev is not None else _DEFAULT_TTF_SEC
53
+ else:
54
+ obs_ttf = max(_MIN_TTF_SEC, min(_MAX_TTF_SEC, ttf_sec))
55
  obs_total = max(_MIN_TOTAL_SEC, min(_MAX_TOTAL_SEC, total_sec))
 
56
  if prev is None:
57
  _latency_ema[key] = (obs_ttf, obs_total)
58
  else:
lumen_router_config.py CHANGED
@@ -53,30 +53,30 @@ GEMINI_MODELS: dict[str, dict[str, Any]] = {
53
  # Быстрая и экономичная (21.07.2026, до 350 токенов/сек).
54
  "gemini-3.5-flash-lite": {
55
  "stream": True,
56
- "search_grounding": False, "map_grounding": True,
57
  },
58
  # Резерв после 3.5 Flash-Lite.
59
  "gemini-3.1-flash-lite": {
60
  "stream": True,
61
- "search_grounding": False, "map_grounding": True,
62
  },
63
  # Баланс скорости и качества. Единственное поколение с квотой search grounding (21/1500).
64
  "gemini-2.5-flash": {
65
  "stream": True,
66
- "search_grounding": True, "map_grounding": True,
67
  },
68
  # Экономичная: скорость важнее глубины рассуждений.
69
  "gemini-2.5-flash-lite": {
70
  "stream": True,
71
- "search_grounding": True, "map_grounding": True,
72
  },
73
  "gemma-4-31b-it": {
74
- "no_system": True, "no_search": True, "stream": True,
75
  },
76
  "gemma-4-26b-a4b-it": {
77
  "no_system": True,
78
  # 24.07.2026: без no_search Gemma получала бы инструменты, которых не поддерживает.
79
- "no_search": True, "stream": True,
80
  },
81
  }
82
  DEFAULT_GEMINI_MODEL = "gemini-3.8-flash"
@@ -259,7 +259,10 @@ def _is_quota_exhausted(provider: str, model_id: str) -> bool:
259
  return True
260
  cooldown_until = entry.get("cooldown_until")
261
  return bool(cooldown_until and cooldown_until > time.time())
262
- except Exception:
 
 
 
263
  return False
264
 
265
  def _skip_exhausted(provider: str, models: list[str]) -> list[str]:
@@ -395,6 +398,8 @@ def _looks_like_heavy_query(text: str) -> bool:
395
 
396
  # ── Эвристика свежести без LLM.
397
  # Ложные срабатывания дёшевы: модель сама решает, вызывать ли поиск.
 
 
398
  _FRESHNESS_QUERY_RE = re.compile(
399
  r"сейчас|сегодня|текущ\w*|последн\w*|актуальн\w*|свеж\w*|недавно|на\s+данный\s+момент"
400
  r"|новост\w*|курс\s+(валют|доллара|евро|рубл\w*)|погод\w*|прогноз\w*"
@@ -406,7 +411,7 @@ _FRESHNESS_QUERY_RE = re.compile(
406
  r"|кто\s+(сейчас|является|президент|премьер|глава|ceo|мэр)"
407
  r"|результат\w*\s+(матч\w*|игр\w*|выбор\w*)"
408
  r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)"
409
- r"|\b202[6-9]\b"
410
  # EN-набор: детект был только русским при 25 языках (внешний аудит). Границы слов обязательны: now ловил know/snow.
411
  r"|\bnow\b|\btoday\b|current\w*|latest|recent\w*|\bbreaking\b"
412
  r"|\bnews\b|weather|forecast|price\w*|\bcost\w*|how\s+much|exchange|\bscore\w*|schedule"
 
53
  # Быстрая и экономичная (21.07.2026, до 350 токенов/сек).
54
  "gemini-3.5-flash-lite": {
55
  "stream": True,
56
+ "search_grounding": False, "map_grounding": True, "url_context": True,
57
  },
58
  # Резерв после 3.5 Flash-Lite.
59
  "gemini-3.1-flash-lite": {
60
  "stream": True,
61
+ "search_grounding": False, "map_grounding": True, "url_context": True,
62
  },
63
  # Баланс скорости и качества. Единственное поколение с квотой search grounding (21/1500).
64
  "gemini-2.5-flash": {
65
  "stream": True,
66
+ "search_grounding": True, "map_grounding": True, "url_context": True,
67
  },
68
  # Экономичная: скорость важнее глубины рассуждений.
69
  "gemini-2.5-flash-lite": {
70
  "stream": True,
71
+ "search_grounding": True, "map_grounding": True, "url_context": True,
72
  },
73
  "gemma-4-31b-it": {
74
+ "no_system": True, "no_search": True, "stream": True, "url_context": True,
75
  },
76
  "gemma-4-26b-a4b-it": {
77
  "no_system": True,
78
  # 24.07.2026: без no_search Gemma получала бы инструменты, которых не поддерживает.
79
+ "no_search": True, "stream": True, "url_context": True,
80
  },
81
  }
82
  DEFAULT_GEMINI_MODEL = "gemini-3.8-flash"
 
259
  return True
260
  cooldown_until = entry.get("cooldown_until")
261
  return bool(cooldown_until and cooldown_until > time.time())
262
+ except Exception as exc:
263
+ # Битое состояние квоты чиним видимой ошибкой в логе, а не молчаливыми
264
+ # лишними попытками: fail-open здесь — пропуск фильтра.
265
+ log.warning("[router] Quota state unreadable, treating %s/%s as available: %s", provider, model_id, exc)
266
  return False
267
 
268
  def _skip_exhausted(provider: str, models: list[str]) -> list[str]:
 
398
 
399
  # ── Эвристика свежести без LLM.
400
  # Ложные срабатывания дёшевы: модель сама решает, вызывать ли поиск.
401
+ # Годы свежести — окном от текущей даты, а не зашитым диапазоном (иначе протухает).
402
+ _FRESHNESS_YEAR_ALTS = "|".join(str(date.today().year + i) for i in range(4))
403
  _FRESHNESS_QUERY_RE = re.compile(
404
  r"сейчас|сегодня|текущ\w*|последн\w*|актуальн\w*|свеж\w*|недавно|на\s+данный\s+момент"
405
  r"|новост\w*|курс\s+(валют|доллара|евро|рубл\w*)|погод\w*|прогноз\w*"
 
411
  r"|кто\s+(сейчас|является|президент|премьер|глава|ceo|мэр)"
412
  r"|результат\w*\s+(матч\w*|игр\w*|выбор\w*)"
413
  r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)"
414
+ rf"|\b(?:{_FRESHNESS_YEAR_ALTS})\b"
415
  # EN-набор: детект был только русским при 25 языках (внешний аудит). Границы слов обязательны: now ловил know/snow.
416
  r"|\bnow\b|\btoday\b|current\w*|latest|recent\w*|\bbreaking\b"
417
  r"|\bnews\b|weather|forecast|price\w*|\bcost\w*|how\s+much|exchange|\bscore\w*|schedule"
lumen_routes.py CHANGED
@@ -39,6 +39,9 @@ from lumen_security import _scrub_identity_leak
39
 
40
  # Транскрипт длинного войса режем сверху — иначе маршрут ниже упрётся в лимиты моделей.
41
  _TRANSCRIPT_MAX_CHARS = 4000
 
 
 
42
 
43
  log = logging.getLogger("bot")
44
 
@@ -351,7 +354,7 @@ async def _transcribe_audio(audio_bytes: bytes, mime: str, chat_id: int, deadlin
351
  f"{bot.GROQ_BASE_URL}/audio/transcriptions",
352
  headers={"Authorization": f"Bearer {bot.GROQ_API_KEY}"},
353
  data=form,
354
- timeout=aiohttp.ClientTimeout(total=_attempt_timeout(bot, deadline), connect=10.0),
355
  ) as resp:
356
  if resp.status >= 400:
357
  body = await resp.read()
 
39
 
40
  # Транскрипт длинного войса режем сверху — иначе маршрут ниже упрётся в лимиты моделей.
41
  _TRANSCRIPT_MAX_CHARS = 4000
42
+ # Транскрибация не должна съедать весь бюджет маршрута: войсы короткие (до
43
+ # VOICE_TRANSCRIBE_MAX_BYTES), turbo-модель отвечает секундами — остаток оставляем модели.
44
+ _TRANSCRIBE_TIMEOUT_SEC = 15.0
45
 
46
  log = logging.getLogger("bot")
47
 
 
354
  f"{bot.GROQ_BASE_URL}/audio/transcriptions",
355
  headers={"Authorization": f"Bearer {bot.GROQ_API_KEY}"},
356
  data=form,
357
+ timeout=aiohttp.ClientTimeout(total=min(_attempt_timeout(bot, deadline), _TRANSCRIBE_TIMEOUT_SEC), connect=10.0),
358
  ) as resp:
359
  if resp.status >= 400:
360
  body = await resp.read()
tests/test_bot_routes.py CHANGED
@@ -1485,6 +1485,43 @@ def test_transcribe_audio_uses_remaining_budget_not_full_timeout(monkeypatch):
1485
  assert captured["timeout"].total < bot.ROUTE_MODEL_TIMEOUT_SEC
1486
 
1487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1488
  def test_ask_openrouter_text_trims_history_with_summary_not_silent_cut(monkeypatch):
1489
  # Аудит A4-05: история резалась молчаливым срезом вместо _trim_history с саммари.
1490
  import lumen_chat_state
 
1485
  assert captured["timeout"].total < bot.ROUTE_MODEL_TIMEOUT_SEC
1486
 
1487
 
1488
+ def test_transcribe_audio_capped_by_own_limit(monkeypatch):
1489
+ # Аудит A2-12: транскрибация ждала остаток всего бюджета и морила модель —
1490
+ # теперь отдельный меньший лимит поверх остатка.
1491
+ import lumen_routes
1492
+ captured = {}
1493
+
1494
+ class _FakeResp:
1495
+ status = 200
1496
+
1497
+ async def __aenter__(self):
1498
+ return self
1499
+
1500
+ async def __aexit__(self, *args):
1501
+ return False
1502
+
1503
+ async def read(self):
1504
+ return b"{}"
1505
+
1506
+ async def json(self, content_type=None):
1507
+ return {"text": "привет"}
1508
+
1509
+ class _FakeSession:
1510
+ def post(self, *args, **kwargs):
1511
+ captured["timeout"] = kwargs.get("timeout")
1512
+ return _FakeResp()
1513
+
1514
+ async def fake_get_http_session():
1515
+ return _FakeSession()
1516
+
1517
+ monkeypatch.setattr(bot, "_get_http_session", fake_get_http_session)
1518
+ monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
1519
+ text = asyncio.run(bot._transcribe_audio(b"ogg-bytes", "audio/ogg", 123, deadline=time.monotonic() + 1000.0))
1520
+ assert text == "привет"
1521
+ assert captured["timeout"].total <= lumen_routes._TRANSCRIBE_TIMEOUT_SEC
1522
+ assert captured["timeout"].total < bot.ROUTE_MODEL_TIMEOUT_SEC
1523
+
1524
+
1525
  def test_ask_openrouter_text_trims_history_with_summary_not_silent_cut(monkeypatch):
1526
  # Аудит A4-05: история резалась молчаливым срезом вместо _trim_history с саммари.
1527
  import lumen_chat_state
tests/test_lumen_model_speed.py CHANGED
@@ -47,11 +47,16 @@ def test_record_response_clamps_outliers_and_ignores_garbage():
47
  assert lumen_model_speed.expected_ttf_sec(key) == lumen_model_speed._MAX_TTF_SEC
48
 
49
 
50
- def test_record_response_defaults_ttf_to_total():
 
 
51
  _reset()
52
  key = lumen_model_speed.speed_key("openrouter", "m:free")
53
  lumen_model_speed.record_response(key, total_sec=30.0)
54
- assert lumen_model_speed.expected_ttf_sec(key) == 30.0
 
 
 
55
 
56
 
57
  def test_first_chunk_limit_respects_floor_and_adapts():
 
47
  assert lumen_model_speed.expected_ttf_sec(key) == lumen_model_speed._MAX_TTF_SEC
48
 
49
 
50
+ def test_record_response_without_ttf_keeps_ttf_ema():
51
+ # Аудит A2-4: нестриминговый ответ первого куска не видел — подстановка total
52
+ # в ttf-EMA завышала first_chunk_limit, зависшая голова держалась дольше.
53
  _reset()
54
  key = lumen_model_speed.speed_key("openrouter", "m:free")
55
  lumen_model_speed.record_response(key, total_sec=30.0)
56
+ assert lumen_model_speed.expected_ttf_sec(key) == lumen_model_speed._DEFAULT_TTF_SEC
57
+ lumen_model_speed.record_response(key, total_sec=6.0, ttf_sec=2.0)
58
+ lumen_model_speed.record_response(key, total_sec=80.0)
59
+ assert lumen_model_speed.expected_ttf_sec(key) < 10.0
60
 
61
 
62
  def test_first_chunk_limit_respects_floor_and_adapts():
tests/test_lumen_router_config.py CHANGED
@@ -20,6 +20,7 @@ logger="bot") ниже продолжает работать так же, как
20
  pytest test_lumen_router_config.py -v
21
  """
22
  import lumen_router_config
 
23
 
24
 
25
 
@@ -695,3 +696,30 @@ def test_successful_usage_clears_exhausted_mark():
695
  assert [m for _, m in lumen_router_config._or_route(["probe/recover:free"])] == ["probe/recover:free"]
696
  finally:
697
  bot.GLOBAL_QUOTA["openrouter"].pop("probe/recover:free", None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  pytest test_lumen_router_config.py -v
21
  """
22
  import lumen_router_config
23
+ from datetime import date
24
 
25
 
26
 
 
696
  assert [m for _, m in lumen_router_config._or_route(["probe/recover:free"])] == ["probe/recover:free"]
697
  finally:
698
  bot.GLOBAL_QUOTA["openrouter"].pop("probe/recover:free", None)
699
+
700
+
701
+ def test_all_gemini_models_declare_url_context_explicitly():
702
+ # Аудит A2-11: дефолт True в conf.get давал инструмент молча — теперь ключ
703
+ # прописан явно у каждой модели (поведение то же, намерение видно).
704
+ missing = [m for m, c in lumen_router_config.GEMINI_MODELS.items() if "url_context" not in c]
705
+ assert missing == []
706
+
707
+
708
+ def test_freshness_year_window_tracks_current_year():
709
+ # Аудит A2-13: зашитый 202[6-9] протухал в 2030 — окно строится от текущей даты.
710
+ assert str(date.today().year) in lumen_router_config._FRESHNESS_QUERY_RE.pattern
711
+ assert lumen_router_config._looks_like_freshness_query("вспомним 2039 год") is False
712
+
713
+
714
+ def test_quota_exhausted_logs_broken_state_loudly(monkeypatch, caplog):
715
+ # Аудит A2-10: битое состояние квоты раньше давало молчаливый fail-open.
716
+ import bot
717
+
718
+ class _BrokenQuota(dict):
719
+ def get(self, *args, **kwargs):
720
+ raise RuntimeError("boom")
721
+
722
+ monkeypatch.setattr(bot, "GLOBAL_QUOTA", _BrokenQuota())
723
+ with caplog.at_level("WARNING", logger="bot"):
724
+ assert lumen_router_config._is_quota_exhausted("openrouter", "m") is False
725
+ assert "Quota state unreadable" in caplog.text