SilverElixir commited on
Commit
3d792d6
·
1 Parent(s): 4ab6cec

P2: extract lumen_errors.py (model error classification/texts, system prompt) with bot.X re-exports (AUD-A-001)

Browse files
Files changed (2) hide show
  1. bot.py +25 -130
  2. lumen_errors.py +147 -0
bot.py CHANGED
@@ -23,7 +23,7 @@ import tempfile
23
  import time
24
  from pathlib import Path
25
  from collections import deque
26
- from datetime import date, datetime
27
  from typing import Any
28
  import urllib.request as _urllib_request
29
 
@@ -46,8 +46,6 @@ from aiogram.types import (
46
  from google import genai
47
  from google.genai import types # нужен тестам как bot.types (сборка Content/Part)
48
 
49
- from system_prompt import SYSTEM_PROMPT
50
-
51
  # язык системных сообщений бота (см. lumen_lang.py): фиксированные строки,
52
  # которые бот отправляет сам (/start, подсказки, ошибки, статусы). Ответы ИИ
53
  # не трогаем — модель отвечает на языке собеседника.
@@ -983,108 +981,6 @@ async def _edit_message_quietly(msg: Message | None, text: str, **kwargs: Any) -
983
 
984
  # разбор ошибок
985
 
986
- def _error_text(e: Exception) -> str:
987
- return " ".join(p for p in [str(e), str(getattr(e, "message", "")), str(getattr(e, "detail", ""))] if p).strip()
988
-
989
- def _error_status(e: Exception, text: str) -> int | None:
990
- for a in ("status_code", "status", "code", "http_status"):
991
- val = getattr(e, a, None)
992
- try:
993
- if val is not None:
994
- return int(val)
995
- except Exception:
996
- pass
997
- m = re.search(r"(?<!\d)(\d{3})(?!\d)", text)
998
- if m:
999
- try:
1000
- return int(m.group(1))
1001
- except Exception:
1002
- pass
1003
- return None
1004
-
1005
- def _classify_model_error(status: int | None, text: str) -> str:
1006
- low = text.lower()
1007
- if status == 429 or any(tok in low for tok in ("resource_exhausted", "too many requests", "rate limit", "quota", "лимит")):
1008
- return "rate_limit"
1009
- if status == 402 or any(tok in low for tok in ("payment required", "paid tier", "requires paid", "billing", "кредит")):
1010
- return "paid"
1011
- if status in {401, 403} or any(tok in low for tok in ("unauthorized", "forbidden", "permission", "blocked")):
1012
- return "forbidden"
1013
- if status in {400, 404} or any(tok in low for tok in ("not found", "invalid argument", "invalid model", "unsupported", "unavailable")):
1014
- return "unavailable"
1015
- return "other"
1016
-
1017
- # НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше _or_error_msg и _gemini_error_msg держали
1018
- # почти идентичную классификацию (rate_limit/paid/forbidden/unavailable) в двух
1019
- # отдельных функциях с чуть разным текстом — риск, что при будущей правке кто-то
1020
- # поправит формулировку в одной и забудет про другую (ровно то дублирование,
1021
- # которого проект и так избегает в других местах, см. _next_fallback_model выше).
1022
- # Общий источник текста для обеих — эта таблица; провайдер-специфичной разницы
1023
- # в тексте больше нет и намеренно: сообщение об ошибке НЕ должно называть
1024
- # "резервного провайдера" — с автоматическим роутером (см. "автоматический выбор
1025
- # модели" ниже) OpenRouter сплошь и рядом оказывается ПЕРВЫМ, а не резервным
1026
- # кандидатом, так что старая формулировка была не просто лишней деталью
1027
- # реализации, а фактически неверной. Упоминания команд /model и /provider тоже
1028
- # убраны целиком — обе команды удалены (см. README, "Автоматический выбор
1029
- # модели"), реального способа переключиться вручную больше нет, и предлагать
1030
- # его пользователю было прямой (и активно вводящей в заблуждение) ошибкой.
1031
- # ВАЖНО для формулировок: Lumen подаётся как единая модель (см. ИДЕНТИЧНОСТЬ
1032
- # в system_prompt.py) — тексты НЕ должны упоминать "модели" во множественном
1033
- # числе или "подбор другой модели": для пользователя есть только Lumen, а
1034
- # переключения внутри — деталь реализации.
1035
- # Сами тексты живут в lumen_lang.py (ключи model_err_*), здесь — тонкая
1036
- # обёртка с языком (lang="en" — дефолт для вызовов без чата, в тестах в том числе).
1037
- _MODEL_ERROR_FALLBACK_MSG = _lang_t(DEFAULT_LANG, "model_err_fallback")
1038
-
1039
- def _model_error_text(kind: str, lang: str = DEFAULT_LANG) -> str:
1040
- key = {
1041
- "rate_limit": "model_err_rate_limit",
1042
- "paid": "model_err_paid",
1043
- "forbidden": "model_err_forbidden",
1044
- "unavailable": "model_err_unavailable",
1045
- }.get(kind, "model_err_fallback")
1046
- return _lang_t(lang, key)
1047
-
1048
- def _or_error_msg(e: Exception, kind: str, lang: str = DEFAULT_LANG) -> str:
1049
- # Сырой текст ошибки API сюда намеренно не подставляется (может содержать
1050
- # внутренние детали инфраструктуры, HTML/JSON или обрывки заголовков) —
1051
- # то же правило, что уже применяется в _gemini_error_msg ниже.
1052
- txt = _error_text(e).strip() or e.__class__.__name__
1053
- status = _error_status(e, txt)
1054
- return _model_error_text(_classify_model_error(status, txt), lang)
1055
-
1056
- class GeminiAllModelsExhaustedError(RuntimeError):
1057
- """Поднимается, когда 429/RESOURCE_EXHAUSTED получен подряд от всех моделей
1058
- из цепочки фоллбека — то есть реально весь бесплатный лимит API-ключа исчерпан,
1059
- а не просто конкретная модель временно занята."""
1060
- def __init__(self, exhausted_models: list[str]) -> None:
1061
- self.exhausted_models = exhausted_models
1062
- super().__init__(f"All Gemini models exhausted quota: {', '.join(exhausted_models)}")
1063
-
1064
- def _next_fallback_model(tried_models: set[str], chain: list[str]) -> str | None:
1065
- """Возвращает первую ещё не испробованную модель из quota_fallback_chain.
1066
- Вынесено в отдельную функцию, т.к. одна и та же проверка используется в
1067
- ask_gemini сразу в трёх ветках (429, timeout, 503/500) — дублирование трёх
1068
- identичных генераторов раньше создавало риск, что при будущей правке кто-то
1069
- поправит один из трёх вызовов и забудет остальные."""
1070
- return next((m for m in chain if m not in tried_models), None)
1071
-
1072
- def _gemini_error_msg(e: Exception, model_id: str, lang: str = DEFAULT_LANG) -> str:
1073
- if isinstance(e, ValueError):
1074
- return str(e)
1075
- if isinstance(e, GeminiAllModelsExhaustedError):
1076
- return _lang_t(lang, "err_quota_exhausted")
1077
- txt = _error_text(e).strip() or e.__class__.__name__
1078
- status = _error_status(e, txt)
1079
- kind = _classify_model_error(status, txt)
1080
- log.debug("[gemini] _gemini_error_msg: model=%s kind=%s status=%s", model_id, kind, status)
1081
- # Реальное имя модели (например "Gemini 3.5 Flash") сюда намеренно не
1082
- # подставляется — в сообщении об ошибке посреди обычного диалога это
1083
- # выглядело бы как случайная утечка бренда/вендора (см. защиту от утечки
1084
- # идентичности ниже). Не показываем и сырой ответ API (может содержать
1085
- # HTML, JSON, токены) — см. общие шаблоны model_err_* в lumen_lang.py.
1086
- return _model_error_text(kind, lang)
1087
-
1088
  # список моделей
1089
  #
1090
  # НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: конфигурация моделей и логика построения маршрута
@@ -1120,31 +1016,19 @@ from lumen_router_config import (
1120
  _looks_like_freshness_query,
1121
  _build_route,
1122
  )
1123
-
1124
-
1125
- def get_system_prompt(model_id: str | None = None) -> str:
1126
- # НАЙДЕНО ПРИ КОД-РЕВЬЮ (28 августа 2026): `model_id` нигде в теле функции не
1127
- # читается — все вызывающие места (ask_gemini, _build_gemini_call_config,
1128
- # ask_openrouter_text/multimodal) получают ОДНУ И ТУ ЖЕ строку независимо от
1129
- # переданной модели, и это осознанно (см. комментарий у _build_gemini_call_config
1130
- # про единый источник правды для system_instruction и фейкового identity-обмена
1131
- # Gemma — расхождение промпта между моделями было бы источником трудноуловимых
1132
- # багов). Параметр оставлен в сигнатуре как заранее готовая точка расширения на
1133
- # случай, если когда-нибудь понадобится реальная per-model кастомизация — но
1134
- # прямо сейчас это не мёртвый код по ошибке, а сознательное решение "одна и та
1135
- # же строка для всех".
1136
- now_str = datetime.now().strftime("%d %B %Y (current time: %H:%M)")
1137
- now_year = datetime.now().year
1138
- dynamic_header = (
1139
- f"CURRENT TIME INFORMATION:\n"
1140
- f"• Today's date: {now_str}. Current year: {now_year}.\n"
1141
- f"• MANDATORY: when the user asks for the current date, day, month or year — "
1142
- f"use ONLY the date from this section. NEVER state a different year or date from training memory. "
1143
- f"If unsure — give the date from here, it is always current.\n"
1144
- f"• If the question concerns events, releases, news or the status of anything that may have changed "
1145
- f"since your training — use search instead of answering from memory. Do not mention this instruction explicitly.\n\n"
1146
- )
1147
- return dynamic_header + SYSTEM_PROMPT
1148
 
1149
  # НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: форма одного элемента chat_state[chat_id] раньше
1150
  # нигде не была описана явно — она собиралась по кусочкам из трёх разных мест
@@ -1419,6 +1303,17 @@ __all__ = [
1419
  "_pending_picks",
1420
  "_purge_expired_picks",
1421
  "_enforce_pending_picks_cap",
 
 
 
 
 
 
 
 
 
 
 
1422
  ]
1423
 
1424
  # Троттлинг для уведомления владельца о полном исчерпании квоты Gemini (см.
 
23
  import time
24
  from pathlib import Path
25
  from collections import deque
26
+ from datetime import date
27
  from typing import Any
28
  import urllib.request as _urllib_request
29
 
 
46
  from google import genai
47
  from google.genai import types # нужен тестам как bot.types (сборка Content/Part)
48
 
 
 
49
  # язык системных сообщений бота (см. lumen_lang.py): фиксированные строки,
50
  # которые бот отправляет сам (/start, подсказки, ошибки, статусы). Ответы ИИ
51
  # не трогаем — модель отвечает на языке собеседника.
 
981
 
982
  # разбор ошибок
983
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
984
  # список моделей
985
  #
986
  # НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: конфигурация моделей и логика построения маршрута
 
1016
  _looks_like_freshness_query,
1017
  _build_route,
1018
  )
1019
+ # Слой ошибок живёт в lumen_errors.py (P2): здесь только реэкспорт имён.
1020
+ from lumen_errors import (
1021
+ _error_text,
1022
+ _error_status,
1023
+ _classify_model_error,
1024
+ _model_error_text,
1025
+ _or_error_msg,
1026
+ GeminiAllModelsExhaustedError,
1027
+ _next_fallback_model,
1028
+ _gemini_error_msg,
1029
+ get_system_prompt,
1030
+ _MODEL_ERROR_FALLBACK_MSG,
1031
+ )
 
 
 
 
 
 
 
 
 
 
 
 
1032
 
1033
  # НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: форма одного элемента chat_state[chat_id] раньше
1034
  # нигде не была описана явно — она собиралась по кусочкам из трёх разных мест
 
1303
  "_pending_picks",
1304
  "_purge_expired_picks",
1305
  "_enforce_pending_picks_cap",
1306
+ # Имена из lumen_errors.py код bot.py сам не читает — только `bot.X` в тестах.
1307
+ "_error_text",
1308
+ "_error_status",
1309
+ "_classify_model_error",
1310
+ "_model_error_text",
1311
+ "_or_error_msg",
1312
+ "GeminiAllModelsExhaustedError",
1313
+ "_next_fallback_model",
1314
+ "_gemini_error_msg",
1315
+ "get_system_prompt",
1316
+ "_MODEL_ERROR_FALLBACK_MSG",
1317
  ]
1318
 
1319
  # Троттлинг для уведомления владельца о полном исчерпании квоты Gemini (см.
lumen_errors.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ lumen_errors.py — разбор ошибок моделей и тексты для пользователя
3
+ (вынесено из bot.py, P2 аудита): классификация rate_limit/paid/forbidden,
4
+ локализованные шаблоны, фолбэк-цепочка, промпт с датой.
5
+
6
+ Связи с рантаймом bot.py — только через отложенный `import bot` внутри функций.
7
+ bot.py реэкспортирует имена — `bot._model_error_text` и т.п. в тестах
8
+ и роутах не менялись.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+ from datetime import datetime
15
+
16
+ from lumen_lang import DEFAULT_LANG, t as _lang_t
17
+ from system_prompt import SYSTEM_PROMPT
18
+
19
+ log = logging.getLogger("bot")
20
+
21
+ def _error_text(e: Exception) -> str:
22
+ return " ".join(p for p in [str(e), str(getattr(e, "message", "")), str(getattr(e, "detail", ""))] if p).strip()
23
+
24
+ def _error_status(e: Exception, text: str) -> int | None:
25
+ for a in ("status_code", "status", "code", "http_status"):
26
+ val = getattr(e, a, None)
27
+ try:
28
+ if val is not None:
29
+ return int(val)
30
+ except Exception:
31
+ pass
32
+ m = re.search(r"(?<!\d)(\d{3})(?!\d)", text)
33
+ if m:
34
+ try:
35
+ return int(m.group(1))
36
+ except Exception:
37
+ pass
38
+ return None
39
+
40
+ def _classify_model_error(status: int | None, text: str) -> str:
41
+ low = text.lower()
42
+ if status == 429 or any(tok in low for tok in ("resource_exhausted", "too many requests", "rate limit", "quota", "лимит")):
43
+ return "rate_limit"
44
+ if status == 402 or any(tok in low for tok in ("payment required", "paid tier", "requires paid", "billing", "кредит")):
45
+ return "paid"
46
+ if status in {401, 403} or any(tok in low for tok in ("unauthorized", "forbidden", "permission", "blocked")):
47
+ return "forbidden"
48
+ if status in {400, 404} or any(tok in low for tok in ("not found", "invalid argument", "invalid model", "unsupported", "unavailable")):
49
+ return "unavailable"
50
+ return "other"
51
+
52
+ # НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше _or_error_msg и _gemini_error_msg держали
53
+ # почти идентичную классификацию (rate_limit/paid/forbidden/unavailable) в двух
54
+ # отдельных функциях с чуть разным текстом — риск, что при будущей правке кто-то
55
+ # поправит формулировку в одной и забудет про другую (ровно то дублирование,
56
+ # которого проект и так избегает в других местах, см. _next_fallback_model выше).
57
+ # Общий источник текста для обеих — эта таблица; провайдер-специфичной разницы
58
+ # в тексте больше нет и намеренно: сообщение об ошибке НЕ должно называть
59
+ # "резервного провайдера" — с автоматическим роутером (см. "автоматический выбор
60
+ # модели" ниже) OpenRouter сплошь и рядом оказывается ПЕРВЫМ, а не резервным
61
+ # кандидатом, так что старая формулировка была не просто лишней деталью
62
+ # реализации, а фактически неверной. Упоминания команд /model и /provider тоже
63
+ # убраны целиком — обе команды удалены (см. README, "Автоматический выбор
64
+ # модели"), реального способа переключиться вручную больше нет, и предлагать
65
+ # его пользователю было прямой (и активно вводящей в заблуждение) ошибкой.
66
+ # ВАЖНО для формулировок: Lumen подаётся как единая модель (см. ИДЕНТИЧНОСТЬ
67
+ # в system_prompt.py) — тексты НЕ должны упоминать "модели" во множественном
68
+ # числе или "подбор другой модели": для пользователя есть только Lumen, а
69
+ # переключения внутри — деталь реализации.
70
+ # Сами тексты живут в lumen_lang.py (ключи model_err_*), здесь — тонкая
71
+ # обёртка с языком (lang="en" — дефолт для вызовов без чата, в тестах в том числе).
72
+ _MODEL_ERROR_FALLBACK_MSG = _lang_t(DEFAULT_LANG, "model_err_fallback")
73
+
74
+ def _model_error_text(kind: str, lang: str = DEFAULT_LANG) -> str:
75
+ key = {
76
+ "rate_limit": "model_err_rate_limit",
77
+ "paid": "model_err_paid",
78
+ "forbidden": "model_err_forbidden",
79
+ "unavailable": "model_err_unavailable",
80
+ }.get(kind, "model_err_fallback")
81
+ return _lang_t(lang, key)
82
+
83
+ def _or_error_msg(e: Exception, kind: str, lang: str = DEFAULT_LANG) -> str:
84
+ # Сырой текст ошибки API сюда намеренно не подставляется (может содержать
85
+ # внутренние детали инфраструктуры, HTML/JSON или обрывки заголовков) —
86
+ # то же правило, что уже применяется в _gemini_error_msg ниже.
87
+ txt = _error_text(e).strip() or e.__class__.__name__
88
+ status = _error_status(e, txt)
89
+ return _model_error_text(_classify_model_error(status, txt), lang)
90
+
91
+ class GeminiAllModelsExhaustedError(RuntimeError):
92
+ """Поднимается, когда 429/RESOURCE_EXHAUSTED получен подряд от всех моделей
93
+ из цепочки фоллбека — то есть реально весь бесплатный лимит API-ключа исчерпан,
94
+ а не просто конкретная модель временно занята."""
95
+ def __init__(self, exhausted_models: list[str]) -> None:
96
+ self.exhausted_models = exhausted_models
97
+ super().__init__(f"All Gemini models exhausted quota: {', '.join(exhausted_models)}")
98
+
99
+ def _next_fallback_model(tried_models: set[str], chain: list[str]) -> str | None:
100
+ """Возвращает первую ещё не испробованную модель из quota_fallback_chain.
101
+ Вынесено в отдельную функцию, т.к. одна и та же проверка используется в
102
+ ask_gemini сразу в трёх ветках (429, timeout, 503/500) — дублирование трёх
103
+ identичных генераторов раньше создавало риск, что при будущей правке кто-то
104
+ поправит один из трёх вызовов и забудет остальные."""
105
+ return next((m for m in chain if m not in tried_models), None)
106
+
107
+ def _gemini_error_msg(e: Exception, model_id: str, lang: str = DEFAULT_LANG) -> str:
108
+ if isinstance(e, ValueError):
109
+ return str(e)
110
+ if isinstance(e, GeminiAllModelsExhaustedError):
111
+ return _lang_t(lang, "err_quota_exhausted")
112
+ txt = _error_text(e).strip() or e.__class__.__name__
113
+ status = _error_status(e, txt)
114
+ kind = _classify_model_error(status, txt)
115
+ log.debug("[gemini] _gemini_error_msg: model=%s kind=%s status=%s", model_id, kind, status)
116
+ # Реальное имя модели (например "Gemini 3.5 Flash") сюда намеренно не
117
+ # подставляется — в сообщении об ошибке посреди обычного диалога это
118
+ # выглядело бы как случайная утечка бренда/вендора (см. защиту от утечки
119
+ # идентичности ниже). Не показываем и сырой ответ API (может содержать
120
+ # HTML, JSON, токены) — см. общие шаблоны model_err_* в lumen_lang.py.
121
+ return _model_error_text(kind, lang)
122
+
123
+
124
+
125
+ def get_system_prompt(model_id: str | None = None) -> str:
126
+ # НАЙДЕНО ПРИ КОД-РЕВЬЮ (28 августа 2026): `model_id` нигде в теле функции не
127
+ # читается — все вызывающие места (ask_gemini, _build_gemini_call_config,
128
+ # ask_openrouter_text/multimodal) получают ОДНУ И ТУ ЖЕ строку независимо от
129
+ # переданной модели, и это осознанно (см. комментарий у _build_gemini_call_config
130
+ # про единый источник правды для system_instruction и фейкового identity-обмена
131
+ # Gemma — расхождение промпта между моделями было бы источником трудноуловимых
132
+ # багов). Параметр оставлен в сигнатуре как заранее готовая точка расширения на
133
+ # случай, если когда-нибудь понадобится реальная per-model кастомизация — но
134
+ # прямо сейчас это не мёртвый код по ошибке, а сознательное решение "одна и та
135
+ # же строка для всех".
136
+ now_str = datetime.now().strftime("%d %B %Y (current time: %H:%M)")
137
+ now_year = datetime.now().year
138
+ dynamic_header = (
139
+ f"CURRENT TIME INFORMATION:\n"
140
+ f"• Today's date: {now_str}. Current year: {now_year}.\n"
141
+ f"• MANDATORY: when the user asks for the current date, day, month or year — "
142
+ f"use ONLY the date from this section. NEVER state a different year or date from training memory. "
143
+ f"If unsure — give the date from here, it is always current.\n"
144
+ f"• If the question concerns events, releases, news or the status of anything that may have changed "
145
+ f"since your training — use search instead of answering from memory. Do not mention this instruction explicitly.\n\n"
146
+ )
147
+ return dynamic_header + SYSTEM_PROMPT