Spaces:
Running
Running
SilverElixir commited on
Add files via upload
Browse files- README.md +12 -3
- bot.py +0 -0
- lumen_state_storage.py +7 -2
README.md
CHANGED
|
@@ -190,7 +190,7 @@ HF Spaces не имеет прямого исходящего доступа к
|
|
| 190 |
|
| 191 |
В рамках этого ограничения `_run_streaming_reply` показывает не всё, что уже накоплено, а срез, растущий по оценённой скорости печати конкретной модели (символов/сек, `lumen_typing_pace.py`) — реальному приходу кусков он "верит" только как верхней границе: если текст пришёл медленнее оценённой скорости, показывается всё, что реально есть, без задержки; ограничивает это именно случай, когда бэкенд прислал крупный кусок быстрее, чем "читалось" бы вслух. Если стрим уже полностью получен, а показан ещё не весь (частый случай для бэкендов без честного токен-в-токен стриминга) — короткая фаза "довывода" (несколько правок с паузой `STREAM_TYPING_TICK_SEC`, не больше `STREAM_TYPING_MAX_CATCHUP_TICKS` штук) достраивает видимый текст до полного, гарантированно укладываясь в `STREAM_TYPING_MAX_CATCHUP_TICKS × STREAM_TYPING_TICK_SEC` секунд сверху реального времени ответа.
|
| 192 |
|
| 193 |
-
**Важно — почему это НЕ статическая таблица "N токенов/сек у модели X", и не нужно искать/обновлять такую таблицу вручную:** у бесплатных моделей OpenRouter реальная скорость отдачи текста не является свойством самой модели — OpenRouter маршрутизирует один и тот же `:free` слаг на разных бэкенд-провайдеров в зависимости от текущей загрузки, и разные бэкенды одной модели могут прислать готовый ответ вообще одним куском вместо потока. Опубликованные кем-либо цифры throughput — скользящая медиана за недавнее окно, устаревающая быстрее, чем список живых/мёртвых моделей в `_OR_MODEL_HEALTH`. Вместо таблицы скорость измеряется по факту на каждом стриме и усредняется экспоненциально (EMA) отдельно по каждой паре provider:model_id (`lumen_typing_pace.py`) — **при добавлении, замене или смене бэкенда любой модели ничего вручную обновлять не нужно**, новая модель просто стартует с `DEFAULT_CHARS_PER_SEC` и за первые несколько ответов сама "нащупывает" свою реальную скорость. Состояние EMA живёт только в памяти процесса (не персистентно) — это чисто косметическая оценка, заново калибруется за пару сообщений после каждого рестарта.
|
| 194 |
|
| 195 |
Тюнинг (обычно трогать не нужно):
|
| 196 |
| Переменная | По умолчанию | Назначение |
|
|
@@ -201,6 +201,16 @@ HF Spaces не имеет прямого исходящего доступа к
|
|
| 201 |
|
| 202 |
Границы самой оценки скорости (`DEFAULT_CHARS_PER_SEC`/`MIN_CHARS_PER_SEC`/`MAX_CHARS_PER_SEC`) — константы в начале `lumen_typing_pace.py`, не через env (это параметры алгоритма сглаживания, а не операционная настройка деплоя).
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
## Защита от промт-инъекций и утечки провайдера
|
| 205 |
|
| 206 |
Бот намеренно скрывает от пользователей, что под капотом Gemini/OpenRouter (см. личность Lumen в `system_prompt.py`). Системный промпт — это ПЕРВЫЙ и самый слабый рубеж: любую LLM в принципе можно уговорить нарушить свои инструкции достаточно творческой промт-инъекцией. Поэтому защита состоит из нескольких независимых слоёв, каждый следующий не полагается на то, что предыдущий сработал:
|
|
@@ -230,7 +240,7 @@ HF Spaces не имеет прямого исходящего доступа к
|
|
| 230 |
|
| 231 |
## Известные ограничения
|
| 232 |
|
| 233 |
-
- **Состояние переживает редеплой только если настроен Upstash.** Без него `chat_state.json`/`global_quota.json` пишутся на эфемерный диск контейнера (`STATE_DIR`) и обнуляются при каждо
|
| 234 |
- **Скачивание с YouTube не поддерживается** (датацентровые IP HF Spaces блокируются на уровне TLS-handshake) — доступен только просмотр/анализ по ссылке через встроенную возможность Gemini, не скачивание файла.
|
| 235 |
- **Автоматический мониторинг падений — только опционально.** Без настроенного `SENTRY_DSN` (см. раздел "Трекинг ошибок" выше) узнать, что бот не отвечает, можно только по логам или жалоба�� пользователей — `bot.log` при этом живёт на эфемерном диске и не переживает редеплой. `_notify_owner` отдельно шлёт ЛС владельцу на два конкретных сценария (срабатывание circuit breaker прокси, полное исчерпание квоты Gemini), но это точечные алерты, а не общая история ошибок. `/diag` — ручная проверка по запросу.
|
| 236 |
- **Стриминг не тестировался против реальных API** (см. выше) — только через мокнутые asyncio-клиент (Gemini) и aiohttp-сессию (OpenRouter, SSE). Логика проверена, но стоит последить за логами `[stream]`/`[identity-leak]`/`[injection-echo]` первые несколько дней после деплоя — особенно для OpenRouter-стриминга, добавленного позже Gemini-версии.
|
|
@@ -260,4 +270,3 @@ pytest test_lumen_formatting.py -v # только один модуль
|
|
| 260 |
`conftest.py` в этой же папке подставляет безопасные заглушки `BOT_TOKEN`/`GEMINI_API_KEY`/`BOT_LOG_PATH` перед импортом `bot.py`, так что реальные секреты и доступ к `/app` для тестов не нужны — актуально для всех четырёх файлов, т.к. общая (autouse) фикстура `_bot_global_state_guard` в `conftest.py` импортирует `bot.py` независимо от того, тестирует ли конкретный файл сам `bot.py` напрямую.
|
| 261 |
|
| 262 |
Проект пока не подключён ни к какому git-хостингу — тесты гоняются только вручную (см. команду выше), автоматического CI-прогона на push/PR сейчас нет.
|
| 263 |
-
|
|
|
|
| 190 |
|
| 191 |
В рамках этого ограничения `_run_streaming_reply` показывает не всё, что уже накоплено, а срез, растущий по оценённой скорости печати конкретной модели (символов/сек, `lumen_typing_pace.py`) — реальному приходу кусков он "верит" только как верхней границе: если текст пришёл медленнее оценённой скорости, показывается всё, что реально есть, без задержки; ограничивает это именно случай, когда бэкенд прислал крупный кусок быстрее, чем "читалось" бы вслух. Если стрим уже полностью получен, а показан ещё не весь (частый случай для бэкендов без честного токен-в-токен стриминга) — короткая фаза "довывода" (несколько правок с паузой `STREAM_TYPING_TICK_SEC`, не больше `STREAM_TYPING_MAX_CATCHUP_TICKS` штук) достраивает видимый текст до полного, гарантированно укладываясь в `STREAM_TYPING_MAX_CATCHUP_TICKS × STREAM_TYPING_TICK_SEC` секунд сверху реального времени ответа.
|
| 192 |
|
| 193 |
+
**Важно — почему это НЕ статическая таблица "N токенов/сек у модели X", и не нужно искать/обновлять такую таблицу вручную:** у бесплатных моделей OpenRouter реальная скорость отдачи текста не является свойством самой модели — OpenRouter маршрутизирует один и тот же `:free` слаг на разных бэкенд-провайдеров в зависимости от текущей загрузки, и разные бэкенды одной модели могут прислать готовый ответ вообще одним куском вместо потока. Опубликованные кем-либо цифры throughput — скользящая медиана за недавнее окно, устаревающая быстрее, чем список живых/мёртвых моделей в `_OR_MODEL_HEALTH` (lumen_router_config.py). Вместо таблицы скорость измеряется по факту на каждом стриме и усредняется экспоненциально (EMA) отдельно по каждой паре provider:model_id (`lumen_typing_pace.py`) — **при добавлении, замене или смене бэкенда любой модели ничего вручную обновлять не нужно**, новая модель просто стартует с `DEFAULT_CHARS_PER_SEC` и за первые несколько ответов сама "нащупывает" свою реальную скорость. Состояние EMA живёт только в памяти процесса (не персистентно) — это чисто косметическая оценка, заново калибруется за пару сообщений после каждого рестарта.
|
| 194 |
|
| 195 |
Тюнинг (обычно трогать не нужно):
|
| 196 |
| Переменная | По умолчанию | Назначение |
|
|
|
|
| 201 |
|
| 202 |
Границы самой оценки скорости (`DEFAULT_CHARS_PER_SEC`/`MIN_CHARS_PER_SEC`/`MAX_CHARS_PER_SEC`) — константы в начале `lumen_typing_pace.py`, не через env (это параметры алгоритма сглаживания, а не операционная настройка деплоя).
|
| 203 |
|
| 204 |
+
### Ручной smoke-тест перед правками стриминга
|
| 205 |
+
|
| 206 |
+
Стриминг покрыт только моками (см. "Известные ограничения" ниже) — ни разу не гонялся против настоящих SSE-ответов Gemini/OpenRouter, юнит-тесты этого в принципе не проверяют. Перед мержем правок в `_run_streaming_reply`/`_gemini_stream_pieces`/`_openrouter_stream_pieces`/`lumen_typing_pace.py` вручную прогнать в реальном чате:
|
| 207 |
+
|
| 208 |
+
1. Короткое сообщение (уместится в одно редактирование) — на Gemini и на OpenRouter отдельно.
|
| 209 |
+
2. Длинное сообщение (> `TG_MAX_LEN`, проверить, что разбивка на несколько сообщений и продолжение работают).
|
| 210 |
+
3. Запрос, где первая (стримящая) модель маршрута заведомо недоступна — проверить тихий откат на нестримленный вызов резервной модели без визуального разрыва.
|
| 211 |
+
|
| 212 |
+
Не заменяет полноценные интеграционные тесты (для них нет надёжного способа держать в CI боевые бесплатные квоты) — но ловит регрессии, которые моки в `test_bot.py` пропускают по конструкции.
|
| 213 |
+
|
| 214 |
## Защита от промт-инъекций и утечки провайдера
|
| 215 |
|
| 216 |
Бот намеренно скрывает от пользователей, что под капотом Gemini/OpenRouter (см. личность Lumen в `system_prompt.py`). Системный промпт — это ПЕРВЫЙ и самый слабый рубеж: любую LLM в принципе можно уговорить нарушить свои инструкции достаточно творческой промт-инъекцией. Поэтому защита состоит из нескольких независимых слоёв, каждый следующий не полагается на то, что предыдущий сработал:
|
|
|
|
| 240 |
|
| 241 |
## Известные ограничения
|
| 242 |
|
| 243 |
+
- **Состояние переживает редеплой только если настроен Upstash.** Без него `chat_state.json`/`global_quota.json` пишутся на эфемерный диск контейнера (`STATE_DIR`) и обнуляются при каждой пересборке образа. См. раздел "Персистентное хранилище" выше — настройка бесплатная и занимает 5 минут.
|
| 244 |
- **Скачивание с YouTube не поддерживается** (датацентровые IP HF Spaces блокируются на уровне TLS-handshake) — доступен только просмотр/анализ по ссылке через встроенную возможность Gemini, не скачивание файла.
|
| 245 |
- **Автоматический мониторинг падений — только опционально.** Без настроенного `SENTRY_DSN` (см. раздел "Трекинг ошибок" выше) узнать, что бот не отвечает, можно только по логам или жалоба�� пользователей — `bot.log` при этом живёт на эфемерном диске и не переживает редеплой. `_notify_owner` отдельно шлёт ЛС владельцу на два конкретных сценария (срабатывание circuit breaker прокси, полное исчерпание квоты Gemini), но это точечные алерты, а не общая история ошибок. `/diag` — ручная проверка по запросу.
|
| 246 |
- **Стриминг не тестировался против реальных API** (см. выше) — только через мокнутые asyncio-клиент (Gemini) и aiohttp-сессию (OpenRouter, SSE). Логика проверена, но стоит последить за логами `[stream]`/`[identity-leak]`/`[injection-echo]` первые несколько дней после деплоя — особенно для OpenRouter-стриминга, добавленного позже Gemini-версии.
|
|
|
|
| 270 |
`conftest.py` в этой же папке подставляет безопасные заглушки `BOT_TOKEN`/`GEMINI_API_KEY`/`BOT_LOG_PATH` перед импортом `bot.py`, так что реальные секреты и доступ к `/app` для тестов не нужны — актуально для всех четырёх файлов, т.к. общая (autouse) фикстура `_bot_global_state_guard` в `conftest.py` импортирует `bot.py` независимо от того, тестирует ли конкретный файл сам `bot.py` напрямую.
|
| 271 |
|
| 272 |
Проект пока не подключён ни к какому git-хостингу — тесты гоняются только вручную (см. команду выше), автоматического CI-прогона на push/PR сейчас нет.
|
|
|
bot.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
lumen_state_storage.py
CHANGED
|
@@ -150,11 +150,16 @@ def _serialize_chat_state(state: dict[str, Any]) -> dict[str, Any]:
|
|
| 150 |
вместо персистентного выбора через удалённую команду /imgmodel. Старые
|
| 151 |
персистентные записи, где эти поля ещё есть (созданные до соответствующих
|
| 152 |
изменений), просто тихо игнорируются при чтении — см. _restore_single_chat в
|
| 153 |
-
bot.py, там нет ни одной попытки их прочитать.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
return {
|
| 155 |
"schema_version": CHAT_STATE_SCHEMA_VERSION,
|
| 156 |
"history": list(state.get("history", [])),
|
| 157 |
-
"quota": state.get("quota", {}),
|
| 158 |
"recent_media_ids": {
|
| 159 |
uid: list(dq) for uid, dq in state.get("recent_media_ids", {}).items()
|
| 160 |
},
|
|
|
|
| 150 |
вместо персистентного выбора через удалённую команду /imgmodel. Старые
|
| 151 |
персистентные записи, где эти поля ещё есть (созданные до соответствующих
|
| 152 |
изменений), просто тихо игнорируются при чтении — см. _restore_single_chat в
|
| 153 |
+
bot.py, там нет ни одной попытки их прочитать.
|
| 154 |
+
"quota" убран отсюда 26 августа 2026 (аудит техдолга) — поле было мёртвым:
|
| 155 |
+
записывалось в ChatState и сериализовалось сюда, но нигде не читалось —
|
| 156 |
+
реальный учёт квоты целиком живёт в модульном GLOBAL_QUOTA (bot.py), per-chat
|
| 157 |
+
квота никогда фактически не использовалась. Старые записи с этим полем в
|
| 158 |
+
хранилище просто тихо игнорируются при чтении, как и остальные удалённые поля
|
| 159 |
+
выше."""
|
| 160 |
return {
|
| 161 |
"schema_version": CHAT_STATE_SCHEMA_VERSION,
|
| 162 |
"history": list(state.get("history", [])),
|
|
|
|
| 163 |
"recent_media_ids": {
|
| 164 |
uid: list(dq) for uid, dq in state.get("recent_media_ids", {}).items()
|
| 165 |
},
|