File size: 5,846 Bytes
3d792d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b65b92
 
3d792d6
 
 
 
 
 
 
 
 
 
 
 
0b65b92
3d792d6
 
 
 
 
 
 
 
 
 
 
 
 
0b65b92
3d792d6
 
 
 
 
 
 
 
 
 
 
0b65b92
3d792d6
 
 
 
 
0b65b92
3d792d6
 
 
 
 
 
 
 
 
 
 
 
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
"""
lumen_errors.py — разбор ошибок моделей и тексты для пользователя
(вынесено из bot.py, P2 аудита): классификация rate_limit/paid/forbidden,
локализованные шаблоны, фолбэк-цепочка, промпт с датой.

Связи с рантаймом bot.py — только через отложенный `import bot` внутри функций.
bot.py реэкспортирует имена — `bot._model_error_text` и т.п. в тестах
и роутах не менялись.
"""
from __future__ import annotations

import logging
import re
from datetime import datetime

from lumen_lang import DEFAULT_LANG, t as _lang_t
from system_prompt import SYSTEM_PROMPT

log = logging.getLogger("bot")

def _error_text(e: Exception) -> str:
    return " ".join(p for p in [str(e), str(getattr(e, "message", "")), str(getattr(e, "detail", ""))] if p).strip()

def _error_status(e: Exception, text: str) -> int | None:
    for a in ("status_code", "status", "code", "http_status"):
        val = getattr(e, a, None)
        try:
            if val is not None:
                return int(val)
        except Exception:
            pass
    m = re.search(r"(?<!\d)(\d{3})(?!\d)", text)
    if m:
        try:
            return int(m.group(1))
        except Exception:
            pass
    return None

def _classify_model_error(status: int | None, text: str) -> str:
    low = text.lower()
    if status == 429 or any(tok in low for tok in ("resource_exhausted", "too many requests", "rate limit", "quota", "лимит")):
        return "rate_limit"
    if status == 402 or any(tok in low for tok in ("payment required", "paid tier", "requires paid", "billing", "кредит")):
        return "paid"
    if status in {401, 403} or any(tok in low for tok in ("unauthorized", "forbidden", "permission", "blocked")):
        return "forbidden"
    if status in {400, 404} or any(tok in low for tok in ("not found", "invalid argument", "invalid model", "unsupported", "unavailable")):
        return "unavailable"
    return "other"

# Единая таблица текстов вместо двух копий классификации: правили одну и забывали вторую.
# Тексты без имён провайдеров/моделей и без /model|/provider: для пользователя есть только Lumen (см. ИДЕНТИЧНОСТЬ).
_MODEL_ERROR_FALLBACK_MSG = _lang_t(DEFAULT_LANG, "model_err_fallback")

def _model_error_text(kind: str, lang: str = DEFAULT_LANG) -> str:
    key = {
        "rate_limit": "model_err_rate_limit",
        "paid": "model_err_paid",
        "forbidden": "model_err_forbidden",
        "unavailable": "model_err_unavailable",
    }.get(kind, "model_err_fallback")
    return _lang_t(lang, key)

def _or_error_msg(e: Exception, kind: str, lang: str = DEFAULT_LANG) -> str:
    # Без сырого текста API: там HTML/JSON и внутренности (то же в _gemini_error_msg).
    txt = _error_text(e).strip() or e.__class__.__name__
    status = _error_status(e, txt)
    return _model_error_text(_classify_model_error(status, txt), lang)

class GeminiAllModelsExhaustedError(RuntimeError):
    """Поднимается, когда 429/RESOURCE_EXHAUSTED получен подряд от всех моделей
    из цепочки фоллбека — то есть реально весь бесплатный лимит API-ключа исчерпан,
    а не просто конкретная модель временно занята."""
    def __init__(self, exhausted_models: list[str]) -> None:
        self.exhausted_models = exhausted_models
        super().__init__(f"All Gemini models exhausted quota: {', '.join(exhausted_models)}")

def _next_fallback_model(tried_models: set[str], chain: list[str]) -> str | None:
    """Первая непробованная из цепочки: один источник вместо трёх копий в ask_gemini."""
    return next((m for m in chain if m not in tried_models), None)

def _gemini_error_msg(e: Exception, model_id: str, lang: str = DEFAULT_LANG) -> str:
    if isinstance(e, ValueError):
        return str(e)
    if isinstance(e, GeminiAllModelsExhaustedError):
        return _lang_t(lang, "err_quota_exhausted")
    txt = _error_text(e).strip() or e.__class__.__name__
    status = _error_status(e, txt)
    kind = _classify_model_error(status, txt)
    log.debug("[gemini] _gemini_error_msg: model=%s kind=%s status=%s", model_id, kind, status)
    # Без имён моделей и сырого API: утечка бренда и мусор (HTML/токены) в диалоге.
    return _model_error_text(kind, lang)



def get_system_prompt(model_id: str | None = None) -> str:
    # model_id не читается осознанно: промпт един для всех, иначе расхождение identity (ревью 28.08.2026).
    now_str = datetime.now().strftime("%d %B %Y (current time: %H:%M)")
    now_year = datetime.now().year
    dynamic_header = (
        f"CURRENT TIME INFORMATION:\n"
        f"• Today's date: {now_str}. Current year: {now_year}.\n"
        f"• MANDATORY: when the user asks for the current date, day, month or year — "
        f"use ONLY the date from this section. NEVER state a different year or date from training memory. "
        f"If unsure — give the date from here, it is always current.\n"
        f"• If the question concerns events, releases, news or the status of anything that may have changed "
        f"since your training — use search instead of answering from memory. Do not mention this instruction explicitly.\n\n"
    )
    return dynamic_header + SYSTEM_PROMPT