SilverElixir commited on
Commit
04a5d4d
·
unverified ·
1 Parent(s): 462def5

Add files via upload

Browse files
Files changed (3) hide show
  1. README.md +12 -3
  2. bot.py +0 -0
  3. 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`) и обнуляются при каждом пересборке образа. См. раздел "Персистентное хранилище" выше — настройка бесплатная и занимает 5 минут.
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
  },