Личный pet-проект: мультимодальный ассистент для обучения — текст, голос, изображения, RAG по собственным документам и генерация картинок (DALL-E 3). Доступен как Telegram-бот и как web-приложение (React) поверх общего backend; оба канала используют общий слой идентичности, а явно связанные Telegram- и GitHub-аккаунты образуют одного пользователя (см. «Связывание Telegram- и GitHub-аккаунтов»).
Развёрнут в production: learn.elivcloud.org. Доступ закрытый (invite-only): в web-приложение входят только GitHub-аккаунты из allowlist, Telegram-бот отвечает только Telegram id из allowlist (оба списка fail-closed — см. «Доступ к боту и идентификация пользователей»).
Ассистент предметно-нейтрален: системные промпты обычного диалога и RAG не привязаны к теме и отвечают на языке пользователя. Python — лишь тема встроенной стартовой базы знаний (см. «Настройка под свою тематику»). Язык интерфейса: web — EN/RU, Telegram-бот (команды, приветствие, справка) — пока только русский.
Репозиторий: github.com/eliv1982/multimodal-learning-assistant
- Telegram-бот — режимы
text/voice/vision/rag: текстовый диалог, голос (Whisper + TTS), анализ изображений, ответы по базе знаний с указанием источников; генерация изображений (/image, DALL-E 3). См. «Режимы» и «Команды». - Web-приложение (React + TypeScript) — вход через GitHub OAuth (с allowlist), связывание с Telegram-аккаунтом, текстовый чат, настройки режима, загрузка и удаление собственных документов (PDF, TXT, MD, DOCX, до 10 MiB). Web-чат только текстовый и RAG не использует.
- Приватный RAG — загруженные документы принадлежат пользователю и видны только ему. Если Telegram- и GitHub-аккаунты связаны, это один пользователь: документы, загруженные через web, используются в RAG-ответах Telegram-бота (
/mode rag). Поверх них — общая встроенная стартовая база знаний. - Провайдеры: Anthropic Claude (основной текстовый диалог и RAG-ответы по умолчанию; переключается на OpenAI через
LLM_PROVIDER) и OpenAI (embeddings, Vision, STT, TTS, DALL-E 3).
- Один процесс
python service_main.py: Telegram-поллинг и FastAPI/Uvicorn работают в одном asyncio-процессе; тот же процесс раздаёт собранное React-приложение (один origin, без CORS). Подробнее — «Единый процесс: Telegram + web». - PostgreSQL — каноническая идентичность пользователей, привязки Telegram/GitHub, серверные web-сессии, настройки, каталог владения документами. Схема создаётся только через Alembic-миграции. См. «PostgreSQL».
- Qdrant в embedded-режиме (локальный каталог
data/qdrant/, отдельный сервер не нужен) — производный, перестраиваемый индекс RAG. Из-за эксклюзивной блокировки каталога приложение запускается ровно в одной реплике. Источник содержимого документов — файлы и.meta.jsonна диске. - Production: Docker-образ (multi-stage: сборка frontend + Python 3.12) и
docker-compose.prod.yml— приложение и PostgreSQL за внешним Traefik (TLS). См. «Production-развёртывание».
- Скопируйте
.env.exampleв.envи заполните:TELEGRAM_BOT_TOKEN— токен от @BotFatherOPENAI_API_KEY— ключ OpenAI (обязателен всегда: embeddings, Vision, STT, TTS, генерация изображений)LLM_PROVIDER— провайдер основного текстового диалога/RAG-ответов:anthropic(по умолчанию) илиopenaiANTHROPIC_API_KEY— ключ Anthropic (обязателен, еслиLLM_PROVIDER=anthropic)TELEGRAM_ALLOWED_USER_IDS— числовые Telegram user id, кому разрешён доступ (через запятую)DATABASE_URL— строка подключения к PostgreSQL (см. раздел «PostgreSQL» ниже)
- Установите зависимости:
pip install -r requirements.txt - Поднимите PostgreSQL (локально или в контейнере) и примените миграции:
alembic upgrade head - Запуск (только Telegram-бот, без web-адаптера):
python main.py
Про совместный запуск Telegram-бота и web-адаптера в одном процессе
(service_main.py) — обязательный вариант, когда работают оба адаптера:
web-маршруты документов/поиска используют тот же локальный Qdrant — см.
раздел «Единый процесс: Telegram + web» ниже. Про web-интерфейс
(React, Node 24 LTS) — раздел «Web-интерфейс (React)» ниже. Запуск
в контейнерах (Docker Compose за Traefik, как в production) — раздел
«Production-развёртывание» в конце.
Доступ к Telegram-боту ограничен списком числовых Telegram user id в
TELEGRAM_ALLOWED_USER_IDS (.env). Если переменная не задана, пустая
или не содержит ни одного корректного id — бот отклоняет всех пользователей
(fail closed), а не становится публичным.
Этот список относится именно к каналу Telegram (utils/access_control.py,
применяется в обработчиках бота) и не даёт доступа к web-входу через
GitHub. Web-вход использует свою аутентификацию — GitHub OAuth и серверную
сессию (см. «GitHub OAuth-логин») — но успешной GitHub OAuth-аутентификации
самой по себе недостаточно: продукт private/invite-only, и
поверх аутентификации стоит отдельный, тоже fail-closed allowlist,
GITHUB_ALLOWED_USER_IDS (utils/github_access_control.py) — числовые
GitHub user id, никогда login/username. Оба списка (Telegram и GitHub)
полностью независимы, каждый ограничивает только свой канал: id в одном
списке не даёт доступа через другой канал, и наличие/отсутствие записи в
одном никак не влияет на проверку по другому.
Telegram-идентификатор — это только внешняя
идентичность адаптера. Сразу после прохождения проверки доступа
from_user.id разрешается в стабильный внутренний UUID (таблицы users +
telegram_accounts в PostgreSQL, см. app/identity.py/db/identity.py), и
именно этот UUID используется дальше как канонический идентификатор
пользователя — в состоянии диалога, владении документами и Qdrant. Создание
внутреннего пользователя происходит только после прохождения allowlist —
никогда как замена проверке доступа.
Помимо Telegram-бота, репозиторий содержит отдельный FastAPI-адаптер
(web/) поверх того же канонического слоя идентичности — без второй
модели пользователя и без второго источника истины. Аутентификация
браузера — настоящая серверная сессия в PostgreSQL (не JWT, не
подписанный клиентский токен): cookie несёт только непрозрачный
криптографически случайный идентификатор, в базе хранится лишь его
SHA-256-дайджест (см. db/auth_sessions.py, app/auth_session.py).
- Запуск как отдельный процесс (не запускает Telegram-бота) — подходит
для web-only развёртывания или локальной работы только над
web/:Требуетpython web_main.pySESSION_SECRET_KEYв.env(подпись CSRF-токенов) — без него адаптер не запустится (fail closed), см..env.example. Поскольку web-маршруты документов/поиска используют Qdrant, запускать этот процесс ОДНОВРЕМЕННО сmain.pyпротив одного и того же локального каталога Qdrant небезопасно и не поддерживается — см. «Единый процесс: Telegram + web» ниже. - Эндпоинты:
GET /healthz(без авторизации),GET /api/me(текущий пользователь —id,created_atи булевоtelegram_linked; сам Telegram id и внутренние детали не отдаются),POST /api/logout(требует валидную сессию и CSRF-заголовокX-CSRF-Token). - CSRF: stateless double-submit cookie, значение криптографически привязано
к самому токену сессии (
web/csrf.py) — не единственная защита от CSRF, но SameSite=Lax остаётся дополнительным слоем, не основным механизмом.
Браузер может дополнительно войти через настоящий OAuth 2.0 Authorization
Code + PKCE (S256) флоу GitHub — GET /api/auth/github/login (редирект на
GitHub) и GET /api/auth/github/callback (обмен кода, резолв личности,
выпуск сессии). GitHub здесь — только внешний провайдер идентичности:
канонический пользователь остаётся тем же users.id UUID, что и для
Telegram, а не второй моделью пользователя.
- Allowlist поверх аутентификации:
продукт private/invite-only, поэтому успешной GitHub OAuth-
аутентификации самой по себе НЕ достаточно для доступа.
/callback, получив проверенныйidGitHub-аккаунта (и ДО того, как для него будет создана строкаgithub_accounts/usersили выпущена сессия), сверяет его сGITHUB_ALLOWED_USER_IDS(utils/github_access_control.py) — тем же fail-closed списком по формату/поведению, что иTELEGRAM_ALLOWED_USER_IDSдля Telegram (см. «Доступ к боту и идентификация пользователей» выше), но полностью отдельным: пусто/не задано = вход запрещён всем, а не "всем разрешено". Неавторизованная попытка получает общий 403 и не оставляет следа в таблицах идентичности (ниgithub_accounts, ниusers). - Устойчивый внешний идентификатор — числовой
idGitHub-аккаунта (GET https://api.github.com/user), НИКОГДА login/username (может измениться) и НИКОГДА email (может отсутствовать/быть приватным). Таблицаgithub_accounts— привязка этого id кusers.id, структурно идентичнаtelegram_accounts. - Access-токен GitHub — эфемерный bootstrap-креденшл: используется один раз
сразу после обмена кода (запрос
GET /user) и нигде не сохраняется, не логируется и не попадает в cookie/ответ. Область доступа (scope) не запрашивается вовсе — минимально необходимый для чтения публичного профиля уровень. - OAuth-транзакция (
state+ PKCEcode_verifier) хранится в PostgreSQL (github_oauth_transactions) короткоживущей (по умолчанию 10 минут,GITHUB_OAUTH_TRANSACTION_TTL_SECONDS) и одноразовой: атомарныйDELETE ... RETURNINGвdb/oauth_transactions.pyгарантирует, что повторное или параллельное использование одного и того жеstateуспевает только один раз, а cleartext PKCE-verifier удаляется из базы сразу после использования (никогда не хранится бессрочно). Сохраняется лишь SHA-256 дайджестstate(не само значение) — как иsession_token_hashдля web-сессий. - Защита от login CSRF:
/loginдополнительно ставит короткоживущую HttpOnly-cookie с тем жеstate, что ушёл в GitHub;/callbackтребует точного совпадения query-параметраstateс этой cookie ДО обращения к базе — иначе злоумышленник, легитимно начавший СВОЙ собственный вход, мог бы завлечь чужой браузер на callback-URL и подсадить жертве сессию от своего же GitHub-аккаунта. Cookie сбрасывается только когда querystateсовпал с ней (эта транзакция действительно текущая для браузера) — несовпадающий/старый callback никогда не сбрасывает cookie другой, всё ещё активной вкладки. - После успешной идентификации всегда выпускается ЗАНОВО созданная сессия
через
app/auth_session.create_session_for_github()— переразрешает GitHub-id → канонический UUID заново, под блокировкой, в ТОЙ ЖЕ транзакции, что и вставка сессии, и отказывает (fail closed), если GitHub-маппинг к этому моменту вообще исчез (например, конкурентный unlink); сама эта функцияauth_generationне проверяет и generation- aware не является — это отдельная, более узкая перепроверка. Проверку generation-staleness (отклоняет попытку, если GitHub-маппинг был отвязан на более позднемauth_generation, чем тот, что зафиксирован при старте этого OAuth-флоу) выполняет ДО этого шагаapp.github_identity. resolve_user_uuid_for_oauth(), вызываемый вweb/github_oauth.pyпередcreate_session_for_github(). Здесь и далее "generation" — счётчикauth_generation/unlink race gate, а не лимит одновременных LLM text- generation запросов (см.app/generation_limits.py). Существующая сессия браузера (если была) не читается, не переиспользуется и не отзывается как побочный эффект чужого входа. - Первый вход через GitHub сам по себе всегда создаёт свой отдельный канонический аккаунт — даже если тот же человек уже существует как Telegram-пользователь; автоматическое слияние по email/username/ эвристике никогда не происходит. Явное связывание этих двух аккаунтов одним и тем же человеком — отдельный, сознательный шаг пользователя, см. «Связывание Telegram- и GitHub-аккаунтов» ниже.
- Требует
GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET/GITHUB_REDIRECT_URIв.env(см..env.example) — без них web-адаптер не запустится (fail closed), это обязательный маршрут адаптера, не опциональный флаг.GITHUB_REDIRECT_URIдолжен точно совпадать с callback URL, зарегистрированным в настройках GitHub OAuth App (https://github.com/settings/developers); используйте отдельные OAuth-приложения для разработки и продакшна. Допускается только https:// в продакшне, либо http:// на буквальном loopback-хосте (127.0.0.1или::1— НЕlocalhost) приWEB_ENV=development; URI не может содержать userinfo/fragment/query, путь обязан быть ровно/api/auth/github/callback.
Каждый GET /api/auth/github/login — неаутентифицированный маршрут.
Чтобы он не мог неограниченно расти в PostgreSQL, db.oauth_transactions. create_sync() выполняет ВСЁ следующее одной атомарной транзакцией,
сериализованной между процессами через SELECT ... FOR UPDATE над
singleton-строкой github_oauth_admission (тот же приём, что
web_session_policy уже использует для политики cookie web-сессий):
- удаляет все просроченные строки
github_oauth_transactions(индексix_github_oauth_transactions_expires_at); - сбрасывает/продлевает фиксированное 60-секундное окно
глобального rate limit (
GITHUB_OAUTH_MAX_STARTS_PER_MINUTE, по умолчанию 30 стартов/минуту — глобально, не по IP: у приложения пока нет доверенного контракта на IP клиента за прокси); - отклоняет запрос (HTTP 429, строка НЕ создаётся) если окно rate limit
исчерпано, ИЛИ если количество ещё живых транзакций уже достигло
жёсткого потолка
GITHUB_OAUTH_MAX_OUTSTANDING_TRANSACTIONS(по умолчанию 200) — оба предела configurable через.env, оба fail closed на некорректном/неположительном значении.
Это делает рост таблицы физически ограниченным даже без внешнего
cron/воркера и даже при нескольких процессах web-адаптера, делящих одну
БД. Внешний rate limit на reverse-proxy/ingress перед
/api/auth/github/login остаётся желательной defense-in-depth мерой
(см. ниже), но не единственной защитой хранилища.
GET /api/auth/github/callback получает code/state в query-строке —
это, по определению OAuth Authorization Code, чувствительные
одноразовые креденшлы. Уровни защиты:
- Код-уровневая гарантия (реализована и протестирована сейчас).
web_main.pyзапускает Uvicorn сaccess_log=False— access-лог с query-строкой никогда не пишется этим процессом.web/app.py'screate_app()дополнительно отключает логгер"uvicorn.access"(defense-in-depth) на случай запуска через голыйuvicornCLI/конфиг (uvicorn web.app:create_app --factory), где access-лог включён по умолчанию. Ответы/api/auth/github/callback(успех и ошибки одинаково) несутReferrer-Policy: no-referrerиCache-Control: no-store. - Production-требование к reverse-proxy/ingress (ОБЯЗАТЕЛЬНОЕ,
документируется здесь, физически НЕ проверяется кодом этого
приложения). ASGI-приложение не может задним числом запретить
вышестоящему reverse-proxy/ingress/балансировщику логировать сырой
входящий URI ДО того, как запрос дойдёт до этого сервера. Поэтому
при развёртывании production reverse-proxy/ingress ОБЯЗАН либо (a)
полностью отключить access-логирование для маршрута
/api/auth/github/callback, либо (b) логировать только путь без query-строки / редактировать (redact)codeиstate. Это требование безопасности, а не опциональная настройка производительности — оно должно быть физически проверено (просмотром реальной конфигурации/логов прокси), а не просто задокументировано; чек-лист для production-стека на Traefik —docs/PRODUCTION_DEPLOYMENT.md, раздел 4a. Дополнительно production ingress должен rate-limit'ить/api/auth/github/login— defense-in-depth поверх описанного выше database-уровневого предела, никогда не замена ему.
Аутентифицированный web-пользователь (вошедший через GitHub) может явно
связать свой аккаунт с существующим Telegram-аккаунтом того же человека —
после этого канонический пользователь один: users.id Telegram-стороны
(Telegram UUID переживает связывание всегда — именно он остаётся
владельцем всех документов/настроек/RAG-данных, а не GitHub-сторона).
- Как это работает для пользователя:
- На сайте (после входа через GitHub) —
POST /api/link/telegram/start(валидная сессия + CSRF) возвращает диплинк видаhttps://t.me/<bot_username>?start=link_<секрет>и время его истечения. - Пользователь открывает диплинк в Telegram и нажимает Start —
существующий allowlist-гейт (
utils.access_control) и резолв Telegram UUID отрабатывают как обычно, ДО обработкиlink_.... - Бот отвечает одним сообщением: успех, «уже связано» или общая ошибка (без деталей — исключает перебор состояния аккаунта).
- После успешного связывания существующие web-сессии GitHub-стороны отзываются (не переносятся) — необходим повторный вход через GitHub; новая сессия резолвится уже в переживший Telegram UUID.
- На сайте (после входа через GitHub) —
- Секрет —
secrets.token_urlsafe(32)(256 бит), генерируется и хешируется (SHA-256) только в прикладном слое (app/telegram_link.py); в PostgreSQL попадает исключительно дайджест (telegram_link_attempts.link_secret_hash) — сырой секрет НИКОГДА не сохраняется, не логируется и не появляется в тексте исключений. TTL — 10 минут по умолчанию (TELEGRAM_LINK_ATTEMPT_TTL_SECONDS, максимум 15). ПовторныйPOST /api/link/telegram/startатомарно заменяет предыдущий незавершённый запрос (не накапливает строки). - Итог связывания (
app/telegram_link.py,db/telegram_link.py): GitHub-привязка (github_accounts) переносится на Telegram UUID; все web-сессии и сама строка попытки связывания GitHub-стороны удаляются; пустой (identity-only)users-ряд GitHub-стороны удаляется. Владение документами/данными в Qdrant НИКОГДА не переписывается — они физически принадлежат Telegram UUID с самого начала, поэтому никакого переноса не требуется. Единственное исключение — настройки (user_preferences):PATCH /api/settingsсоздаёт такую строку у любого web-пользователя, и одна лишь сохранённая настройка не должна навсегда блокировать связывание.- Значения по умолчанию — эффективные, а не сохранённые.
BOT_MODE(режим) иDEFAULT_VOICE(голос) применяются при чтении, когда у пользователя нет сохранённого значения: нет строки, либоmode/voiceравен NULL (нераспознанный сохранённыйmodeтоже читается какBOT_MODE, но при чтении не переписывается). Единый резолвер —app/preferences.py— используют и Telegram (UserSession.get_mode/get_voice), иGET /api/settings(он по-прежнему ничего не пишет), поэтому они всегда согласованы./startНЕ создаёт и НЕ обновляетuser_preferences: строка появляется, только когда пользователь что-то выбрал (/mode,/voice,PATCH /api/settings) либо когда она перенесена при слиянии.BOT_MODE/DEFAULT_VOICEпроверяются при загрузке конфигурации (config.py): неподдерживаемое значение — ошибка запуска, а не некорректное состояние в базе. - Классификация строки при слиянии. Ø — строки нет; D — строка,
эквивалентная умолчанию (её удаление не меняет наблюдаемое поведение);
M — существенная. Строка существенна, если
voice IS NOT NULL ИЛИ mode NOT IN (NULL, текущий BOT_MODE);updated_atникогда не учитывается. Значит(NULL, NULL)и(BOT_MODE, NULL)— D; неосновной каноничныйmode, любой непустойvoice(даже равныйDEFAULT_VOICE) и нераспознанныйmode— M. Принятый компромисс: явный выбор текущего режима по умолчанию и историческая автоматическая запись старого/startнеразличимы, оба — D; еслиBOT_MODEпозже изменится, старая строка (mode=text) станет M и молча не удаляется. - Матрица (источник — GitHub/web, цель — Telegram): Ø/Ø, Ø/D, D/Ø,
D/D → слияние, строк настроек не остаётся; Ø/M, D/M → слияние, строка
цели без изменений (D источника отбрасывается); M/Ø → слияние, вся
строка источника (
mode,voice,updated_at) переносится на цель; M/D → строка D цели удаляется, затем вся строка источника переносится; M/M → общее отклонение, обе строки без изменений (победитель не выбирается, поля не смешиваются, попытка расходуется как при любом детерминированном отказе). Документы по-прежнему жёсткий блокер и проверяются РАНЬШЕ настроек.
- Значения по умолчанию — эффективные, а не сохранённые.
- Отклонение (общая формулировка в Telegram, без деталей — исключает перебор состояния): диплинк недействителен/истёк/уже использован; Telegram-аккаунт уже связан с ДРУГИМ GitHub-аккаунтом; GitHub-сторона уже связана с ДРУГИМ Telegram-аккаунтом; GitHub-привязка исчезла (отвязана параллельно); GitHub-сторона не «чистый» identity-аккаунт (несёт собственные документы — слияние было бы неоднозначным и данные должны остаться доступными, а не молча потеряться); у ОБЕИХ сторон существенные настройки (M/M — ни одна не выбирается молча).
POST /api/unlink/github(валидная сессия + CSRF) отвязывает GitHub от текущего канонического пользователя:- если у пользователя есть Telegram-привязка — GitHub-привязка убирается, пользователь и все его данные остаются;
- если Telegram-привязки нет и данных (документов/настроек) тоже нет — аккаунт GitHub-only полностью удаляется (нечего терять);
- если Telegram-привязки нет, но данные ЕСТЬ — запрос атомарно отклоняется (HTTP 409): данные никогда не остаются недостижимыми.
- Успешная отвязка очищает cookie сессии/CSRF в ответе; при отклонении (409) сессия и cookie не трогаются.
- Порядок блокировок (защита от deadlock между конкурентными
попытками связывания/отвязки/выпуска сессии для одного и того же
пользователя) —
advisory-блокировка нужного flow (когда применима: redemption —
pg_advisory_xact_lock(telegram_user_id); unlink —pg_advisory_xact_lock(-github_user_id); создание попытки — без advisory-блокировки) →github_accounts(при нескольких строках — с явнымORDER BY) →users→ policy/admission-синглтон, когда требуется (web_session_policyпри выпуске сессии;github_oauth_admissionпри успешной отвязке) → мутация строкиtelegram_link_attempts— ПОСЛЕДНИЙ шаг иерархии блокировок (после неё не берётся ни одна блокировка из предыдущих позиций) и всегда одним атомарным оператором (INSERT ... ON CONFLICT/DELETE ... RETURNING), никогда отдельным предварительнымSELECT ... FOR UPDATE. Это последний шаг иерархии, а не последний оператор транзакции: redemption берёт advisory-блокировку,github_accountsиusers, забирает (claim) попытку и уже ПОСЛЕ этого, в той же транзакции и под уже удерживаемыми блокировками, выполняет классификацию отказа, проверку документов, классификацию/нормализацию/перенос настроек и мутации слияния. Строкиuser_preferencesвсегда идут ПОСЛЕusers: redemption переносит настройки под удерживаемыми блокировкамиusers, аdb/preferences.py(set_mode_sync,set_voice_sync) перед записью берётFOR KEY SHAREна строку владельца (иначе запись настроек Telegram-стороны параллельно с redemption давала настоящий deadlock — воспроизведён и закрыт тестомtests/test_stage7b3p_preference_link_compat.py). Ни одна операция не блокируетgithub_accounts/users, а затем ждёт уже существующей строкиtelegram_link_attempts— см.db/telegram_link.py's docstring иtests/test_stage6c_lock_ordering.py(реальный PostgreSQL, реальные потоки и детерминированное наблюдение порядка SQL-операторов, доказательство отсутствия deadlock для критичных сценариев гонки). TELEGRAM_BOT_USERNAME(.env) — имя бота для диплинка; валидируется изолированным web-safe модулем (telegram_link_config.py, никогда не импортируетTELEGRAM_BOT_TOKEN). В отличие от остальных обязательных креденшлов адаптера — отсутствие/некорректность НЕ останавливает запуск web-адаптера и не ломает GitHub-логин//api/me/ logout:POST /api/link/telegram/startв этом случае просто отвечает общим HTTP 503.
Все эндпоинты требуют валидную серверную сессию (иначе HTTP 401);
изменяющие запросы (POST/PATCH) дополнительно требуют CSRF-заголовок
X-CSRF-Token (иначе HTTP 403). Идентификатор пользователя никогда не
принимается от клиента — только из сессии.
POST /api/chat— тело{"message": "...", "history": [{"role": "user"|"assistant", "content": "..."}]}, ответ{"text": "..."}(Cache-Control: no-store). Web-чат только текстовый: режим фиксируется на сервере (text), клиент не может его передать, а сохранённый режим пользователя (/mode,PATCH /api/settings) на web-чат не влияет. Web-чат не использует RAG и загруженные документы — ответ строится без поиска по базе знаний. История диалога не сохраняется на сервере — клиент сам присылает свою ограниченную историю (лимиты те же, что в прикладном ядре: сообщение ≤ 4000 символов, история ≤ 20 сообщений и ≤ 20 000 символов суммарно). Ответы об ошибках — фиксированные: 422 (некорректный запрос), 429 (генерация уже идёт — не более одной на пользователя и четырёх на процесс), 504 (таймаут), 502 (сбой генерации).GET /api/settings→{"mode": "..."}— эффективный режим: сохранённый, а без сохранённого значения — настроенныйBOT_MODE(по умолчаниюtext); чтение ничего не записывает.PATCH /api/settings— тело{"mode": "text"|"voice"|"vision"|"rag"}; сохраняет тот же durable-режим, что и Telegram/mode. Он меняет поведение Telegram-бота, но не web-чата. Голос через web не настраивается (только/voiceв Telegram).- Тела
POST /api/chatиPATCH /api/settingsограничены 64 KiB фактически полученных байт (HTTP 413) — ограничение применяется только к этим двум маршрутам. Любой невалидный запрос получает обезличенный ответ 422{"detail": "Invalid request"}без эха введённых данных.
Документы принадлежат пользователю: идентификатор берётся только из
сессии, клиентского идентификатора нет. Документ виден и удаляется только
владельцем — чужой и несуществующий id дают один и тот же 404. Правила сессии и CSRF — те же, что выше (изменяющие запросы — с
X-CSRF-Token).
GET /api/documents?limit=&offset=— каталог активных документов пользователя (limit1–100, по умолчанию 20; без total/курсора). Элемент — толькоid,display_name,created_at.GET /api/documents/{id}— один документ.POST /api/documents—multipart/form-data, полеfile; успех —201и та же сводка. Поддерживаются.pdf,.txt,.md,.docx, не более 10 MiB (MAX_DOCUMENT_SIZE_BYTES), имя файла — не длиннее 255 символов. Это та же транзакция загрузки/индексации, что и в Telegram (app/documents.py); успех сообщается, только когда документ стал активным.DELETE /api/documents/{id}—204; документ удаляется вместе с его данными в индексе.POST /api/retrieval/search— «сырой» поиск по документам без генерации текста; React-интерфейс его не использует.- Загруженные документы приватны и участвуют в RAG-ответах Telegram-бота,
когда у пользователя активен режим
rag. Web-чат их не использует. - При слиянии двух разных аккаунтов документы не переносятся: собственные документы GitHub-стороны блокируют связывание (см. «Связывание Telegram- и GitHub-аккаунтов»). Если RAG нужен в Telegram, свяжите аккаунты ДО загрузки документов через web.
Локальный Qdrant (QdrantClient(path=...), rag/index.py) держит
эксклюзивную блокировку каталога на диске всё время жизни клиента — два
разных OS-процесса не могут безопасно открыть один и тот же локальный
каталог Qdrant одновременно. Поскольку web-маршруты документов и поиска
используют тот же Qdrant, поддерживаемая production-топология для
Telegram + web — один процесс:
python service_main.py
service_main.py — единая точка входа/composition root: запускает
Telegram-поллинг (тот же AsyncTeleBot из bot.py, тот же setup_bot()
из main.py) и web/-адаптер (web.app.create_app(), через
программный API uvicorn.Server) конкуренто в одном asyncio-процессе,
владея ровно ОДНИМ process-local Qdrant-синглтоном
(rag.index.get_vector_index()) — второй клиент Qdrant никогда не
создаётся. Требует переменные окружения обоих адаптеров одновременно
(TELEGRAM_BOT_TOKEN, SESSION_SECRET_KEY, DATABASE_URL и т.д. — см.
разделы выше); WEB_HOST/WEB_PORT — те же переменные, что и у
web_main.py.
- Если любой из двух адаптеров (Telegram-поллинг или Uvicorn) завершается неожиданно — при старте или во время работы, — второй адаптер останавливается, и процесс завершается с ошибкой; он никогда не продолжает работу бесконечно только с одним живым адаптером.
- При штатной остановке (Ctrl+C) оба адаптера получают кооперативный
сигнал остановки, им даётся время корректно завершиться, после чего
общие ресурсы процесса (сессия Telegram-бота, Qdrant-синглтон,
DB-движок) закрываются ровно один раз —
web/-адаптер сам не закрывает DB-движок в этом режиме (create_app(owns_db_lifecycle=False)), это исключительно ответственность composition root (main.shutdown_bot()), чтобы два независимых владельца никогда не гонялись за закрытием одного и того же ресурса. main.py(только Telegram) иweb_main.py(только web, см. выше) остаются рабочими как САМОСТОЯТЕЛЬНЫЕ адаптеры по отдельности — просто не одновременно друг с другом против одного каталога Qdrant.- При остановке Telegram-адаптера
service_main.pyдожидается завершения не только самого поллинга, но и всех ещё не завершённых задач-обработчиков обновлений (AsyncTeleBot._pending_tasks), прежде чем начинать закрытие общих ресурсов — как и незапрошенная отмена любого из адаптеров, это не может пройти незамеченным. Посколькуservice_main.pyопирается на приватные детали реализацииuvicorn(_TrackedLifespanOn) иpyTelegramBotAPI(_pending_tasks), обе зависимости зафиксированы вrequirements.txtТОЧНОЙ версией (uvicorn==0.52.4,pyTelegramBotAPI==4.36.1), а не диапазоном — при обновлении любой из них нужно заново сверитьservice_main.pyс новой установленной реализацией.
Каталог frontend/ — React + TypeScript (Vite) приложение поверх
backend-контракта, описанного выше. Оно не дублирует логику идентичности,
сессии или CSRF — каноническая личность приходит только из серверной сессии
(HttpOnly cookie, которую JavaScript не читает и не хранит); внутренний UUID
пользователя в интерфейсе не показывается. Единственный источник состояния
входа во фронтенде — AuthContext; HTTP 401 на любой авторизованный запрос
возвращает интерфейс на экран входа.
Вошедший пользователь видит «оболочку» с панелями в таком порядке: Account connections → Settings → Documents → Chat.
- Язык интерфейса. Web-интерфейс двуязычный (EN/RU); переключатель языка
виден на каждом экране, включая экран входа. Начальный язык определяется
автоматически по языку браузера (иначе — английский), выбор пользователя
запоминается в
localStorageпод ключомmla.locale— это единственное, что фронтенд вообще хранит в браузерном хранилище (см. ниже). Переключение языка интерфейса не влияет на язык ответов ассистента: web-чат и RAG всегда отвечают на языке сообщения пользователя, а не на языке интерфейса. Telegram-интерфейс (команды, приветствие, справка) пока остаётся только на русском. - Вход и сессия. Вход — только через GitHub (OAuth 2.0 Authorization
Code + PKCE, см. «GitHub OAuth-логин»):
state, PKCE и сессия целиком на стороне backend, браузер получает лишь HttpOnly-cookie. Состояние восстанавливается изGET /api/me(id,created_at,telegram_linked), выход —POST /api/logout. Пока выход выполняется, панели недоступны для действий; если он не удался, сессия остаётся действующей, а введённый в чат текст сохраняется. Telegram-allowlist (TELEGRAM_ALLOWED_USER_IDS) к web-входу не применяется. - Связывание с Telegram (Account connections). Вошедший пользователь
создаёт диплинк на Telegram (
POST /api/link/telegram/start); связывание завершается в Telegram (Start в боте), а не на сайте. Сайт статус сам не опрашивает — пользователь нажимает «Check link status» вручную. При слиянии двух разных аккаунтов существующие web-сессии GitHub-стороны отзываются, и нужен повторный вход через GitHub (см. «Связывание Telegram- и GitHub-аккаунтов»). «Disconnect GitHub web access» отвязывает GitHub (с подтверждением; завершает эту web-сессию) и не является отвязкой Telegram — её в интерфейсе нет. - Settings. Одно поле — предпочитаемый режим
text/voice/vision/rag. Это тот же сохранённый режим, что и Telegram/mode: он меняет поведение Telegram-бота. Пока значение не сохранено, действует настроенныйBOT_MODE(не обязательноtext). Web-чат от этого режима не зависит и остаётся текстовым; выбора голоса в web нет. - Documents. Список, загрузка и удаление собственных документов (PDF,
TXT, MD, DOCX, до 10 MiB) — см. «Web-документы». Документы приватны,
используются Telegram-ботом при активном режиме
ragи не используются web-чатом; при слиянии аккаунтов они не переносятся и могут блокировать связывание — поэтому связывайте Telegram до загрузки, если RAG нужен там. - Chat. Текстовый чат: локальная расшифровка живёт только в состоянии
страницы (обновление, выход или истёкшая сессия очищают её; ничего не
пишется в
localStorage/sessionStorage/IndexedDB/cookie), клиент сам присылает ограниченную недавнюю историю (лимиты — в «Web-чат и настройки»). Чат не использует RAG/документы и не зависит от сохранённого режима Telegram.
Требования: Node.js 24 LTS и npm (проверено на Node 24 / npm 11).
cd frontend
npm ci # ставит ровно то, что зафиксировано в package-lock.json
npm run lint
npm run typecheck
npm test
npm run build # -> frontend/dist (в репозиторий не коммитится)
frontend/dist — сгенерированный каталог (в .gitignore): его нужно
собрать на машине, где запускается backend. Полный набор проверок backend и
frontend — в разделе «Проверки» ниже.
- В
.envдля локальной разработки (только так cookie безSecureдопускаются — см.web_config.py):В настройках dev-OAuth-приложения GitHub «Authorization callback URL» должен быть точно таким же. Callback URL — это origin Vite, а не backend: браузер работает только с ним, аWEB_ENV=development WEB_COOKIE_SECURE=false GITHUB_REDIRECT_URI=http://127.0.0.1:5173/api/auth/github/callback/api/...проксируется дальше. - Backend:
python web_main.py(илиpython service_main.py) — слушает127.0.0.1:8000. - Frontend:
cd frontend && npm run dev, затем откройтеhttp://127.0.0.1:5173(именно127.0.0.1, неlocalhost: cookie и зарегистрированный callback URL привязаны к хосту).
- Vite проксирует
/apiи/healthzнаhttp://127.0.0.1:8000(frontend/vite.config.ts). Для браузера всё остаётся одним origin, поэтому CORS не нужен и не добавляется. Если backend слушает другой порт (WEB_PORT), поправьтеBACKEND_ORIGINвfrontend/vite.config.ts. - Порт Vite зафиксирован (
5173,strictPort) — иначе callback URL перестал бы совпадать с зарегистрированным. - Вход: кнопка «Sign in with GitHub» — обычная ссылка (полная навигация
браузера) на
/api/auth/github/login, не AJAX:state, PKCE и cookie привязки по-прежнему целиком на стороне backend. После успешного входа callback редиректит на фиксированный путь/(безnext/return_to). - Cookie: в разработке —
sessionиcsrf_tokenбезSecure; в production —__Host-sessionи__Host-csrf_tokenсSecure,Path=/, безDomain. Фронтенд читает только CSRF-cookie (сначала__Host-csrf_token, затемcsrf_token) и перед каждым изменяющим запросом заново отправляет её значение в заголовкеX-CSRF-Token; нигде не сохраняет и не логирует. - Фронтенд не хранит в браузере ничего, кроме выбранного языка интерфейса
(
localStorage, ключmla.locale— см. «Язык интерфейса» выше);sessionStorage/IndexedDB не используются вовсе,localStorageне используется ни для чего другого (аутентификация, сессия, чат, документы), и фронтенд сам не пишет cookie. Не включайте логирование запросов dev-прокси: callback содержитcode/state(требования выше остаются в силе).
Отдельного web-сервера или контейнера для фронтенда нет: собранное
приложение раздаёт тот же FastAPI-процесс (python service_main.py /
web_main.py, web/frontend.py).
cd frontend && npm ci && npm run build # до запуска процесса
(В Docker-образе это делает отдельная build-стадия Dockerfile — вручную
собирать frontend для контейнерного запуска не нужно.)
GET /→frontend/dist/index.html(Cache-Control: no-store).GET /assets/...→ только обычные файлы строго внутриfrontend/dist/assets(проверка по полностью разрешённому пути:.., симлинки и абсолютные пути не выводят за каталог), сCache-Control: public, max-age=31536000, immutable— безопасно, потому что имена файлов Vite содержат хеш содержимого.- Никакого SPA catch-all: любой другой путь, включая опечатку в
/api/..., остаётся обычным 404;/api/*и/healthzникогда не перехватываются. Исходники фронтенда, репозиторий,data/и загрузки не раздаются. - Если
frontend/distне собран,/и/assets/...отвечают 404 (в лог пишется предупреждение) — импорт, создание приложения и тесты backend от сборки не зависят. - Production остаётся same-origin: CORS не настраивается. Cookie — только
Secure/__Host-(WEB_ENV=productionиWEB_COOKIE_SECURE=true— значения по умолчанию).
Развёртывание и безопасность. Репозиторий содержит Dockerfile и
docker-compose.prod.yml (см. «Production-развёртывание»), но не сам
reverse proxy: Traefik, TLS и DNS управляются вне репозитория. Ниже
требования к проксирующей стороне; код приложения физически проверяет не
все из них (то, что не проверяется, нужно проверить по реальной
конфигурации proxy):
- HTTPS терминируется на внешнем reverse proxy;
GITHUB_REDIRECT_URI— этоhttps://-адрес того же публичного хоста, а не адрес backend-порта. - Same-origin:
/,/assets/...,/api/...и/healthzодного публичного хоста идут на один и тот же процесс приложения (service_main.pyилиweb_main.py). Фронтенд и API не разносятся по разным origin: CORS не настроен, а CSRF-защита опирается на Same-Origin Policy. - Secure-cookie:
WEB_ENV=production,WEB_COOKIE_SECURE=true(значения по умолчанию) — cookie__Host-sessionи__Host-csrf_token. - Query-строка
/api/auth/github/callback(code/state) не должна попадать в логи proxy: отключите access-лог для этого маршрута либо логируйте только путь / редактируйте эти параметры — см. «Приватность callback». - Rate limit
/api/auth/github/loginна proxy — defense-in-depth поверх database-уровневого ограничения (не замена ему).
| Команда | Описание |
|---|---|
/mode text |
Текстовый диалог на любую тему (LLM_PROVIDER: Anthropic Claude по умолчанию, либо OpenAI) |
/mode voice |
Голосовые ответы (Whisper + TTS) |
/mode rag |
Ответы по базе знаний с указанием источников |
/mode vision |
Анализ изображений (код, ошибки, схемы) |
Режим можно переключать кнопками: команда /mode без аргумента открывает клавиатуру выбора.
/help— справка по боту/mode— текущий режим и кнопки переключения (или/mode text/voice/rag/vision)/voice— выбор голоса для TTS (alloy, echo, nova, fable, onyx, shimmer);/voices— список голосов/reset— очистка истории диалога/stats— статистика базы знаний (RAG)/image <описание>— генерация изображения по описанию (DALL-E 3)
Генерация изображений также срабатывает по фразам вроде «Нарисуй…», «Создай изображение…» в любом режиме.
Ассистент уже предметно-нейтрален «из коробки»: системные промпты обычного диалога и RAG (см. «Системные промпты» ниже) не завязаны на Python и отвечают на языке пользователя. Из встроенного в поставку к Python реально привязана только стартовая база знаний (см. ниже): текст Telegram-приветствия/справки предметно-нейтрален и упоминает Python лишь как пример темы, а то, что он сейчас на русском, — это язык интерфейса Telegram, а не тематика или язык ответов ассистента (см. «Язык интерфейса» выше). Чтобы полностью перейти на свою тематику (другой язык программирования, предмет, корпоративная база знаний, поддержка и т.п.), обычно достаточно двух вещей:
- Встроенная база знаний — ровно четыре версионируемых файла в
data/documents/, перечисленные явным списком вconfig.BUILTIN_REFERENCE_FILES(см. ниже) — это НЕ произвольное сканирование папки: случайный.txt/.md-файл, оставшийся вdata/documents/, в индекс не попадёт. - Отправьте документ боту в Telegram или загрузите его в web-интерфейсе (
.txt,.md,.pdf,.docx), чтобы добавить СВОИ материалы — он сохранится вdata/documents/uploads/(файл + сопроводительный.meta.jsonс оригинальным именем) и будет проиндексирован индивидуально, независимо от встроенного списка. Такой документ приватный: его видит и может использовать в RAG-ответах только тот пользователь (по внутреннему UUID, см. выше), который его загрузил — другим пользователям бота он не виден. Строка в PostgreSQL (documents) хранит владение/каталог; физический файл +.meta.jsonостаются источником содержимого, Qdrant — производный индекс. - При старте бот автоматически сверяет Qdrant со встроенными файлами (изменившиеся или новые — без лишних повторных обращений к эмбеддингам при неизменном содержимом).
- В режиме
/mode ragответы строятся на основе: встроенной базы знаний (общая, видна всем пользователям) плюс собственных приватных документов запрашивающего пользователя — приватные документы других пользователей никогда не участвуют в поиске. В конце сообщения указывается источник (имя файла). Команда/statsсчитает документы по той же границе видимости: общая база + только ваши собственные загрузки.
Чем полнее и аккуратнее база документов, тем точнее ответы в режиме RAG.
RAG использует Qdrant (локальный persistent-режим, qdrant-client), а не ChromaDB:
- Данные хранятся локально в
data/qdrant/— отдельный Qdrant-сервер не требуется, сетевые обращения к нему отсутствуют. No Qdrant API key or Qdrant server is required in local mode. - Qdrant — это производное (rebuildable) состояние, а не источник истины. Источник истины — версионируемые Markdown-файлы, перечисленные в
config.BUILTIN_REFERENCE_FILES(см. ниже) и, для загруженных пользователями документов (Telegram или web), пара «физический файл +.meta.json» вdata/documents/uploads/. - Полная переиндексация из исходников (без обращения к устаревшему ChromaDB) выполняется вручную, ТОЛЬКО как модуль (прямой запуск файла не работает — см. docstring скрипта):
Dry-run (без
python -m scripts.rebuild_qdrant # dry-run: только проверка и подсчёт, без изменений python -m scripts.rebuild_qdrant --apply # реальная переиндексация (не разрушает существующий индекс: сверяет с источником, недостающее/изменившееся переиндексирует, лишнее удаляет только после успеха всех документов)--apply) не требует никаких провайдерских ключей (TELEGRAM_BOT_TOKEN/OPENAI_API_KEY/ANTHROPIC_API_KEY) и не требует файла.env— он только читает исходники на диске (встроенные Markdown-файлы +.meta.json-сайдкары загрузок) и ничего не пишет: ни в Qdrant, ни вbot.log.--applyтребует обычной конфигурации эмбеддингов/провайдера (OPENAI_API_KEY), поскольку реально обращается к OpenAI Embeddings и мутирует Qdrant. - Старое локальное хранилище ChromaDB (
data/chroma_db/), если оно осталось от предыдущей версии, не мигрируется автоматически и ни для чего в рантайме больше не используется. После проверки работы Qdrant его можно удалить вручную. - Владение приватными документами хранится в Qdrant как
owner_user_uuid(канонический внутренний UUID) — не как числовой Telegram id. Коллекция названа с суффиксом_uuid_v1(rag/constants.py): это отдельная, «чистая» коллекция, не смешивающая старые записи с числовым владельцем и новые — с UUID. Старая коллекция (если существовала) физически остаётся на диске нетронутой. - Если в
data/documents/uploads/остались сайдкары старой (v2, числовой Telegram-владелец) схемы, переведите их на v3 ПЕРЕД обычной переиндексацией:python -m scripts.migrate_sidecars_v2_to_v3 # dry-run python -m scripts.migrate_sidecars_v2_to_v3 --apply # реальная миграция сайдкаров (резолвит/создаёт внутренних пользователей в PostgreSQL, физический файл не трогает) python -m scripts.rebuild_qdrant --apply # обычная переиндексация уже мигрированных (v3) сайдкаров
config.BUILTIN_REFERENCE_FILES явно перечисляет четыре версионируемых (закоммиченных в репозиторий) справочных файла на английском языке в data/documents/: python-fundamentals.md, functions-classes-errors.md, testing-debugging.md, async-python-and-apis.md. Это готовый пример/отправная точка для режима RAG, а не единственно возможная тема ассистента — они демонстрируют кросс-языковой RAG: материалы на английском, а ответ ассистента следует языку вопроса пользователя (это не то же самое, что язык интерфейса Telegram, который пока только русский, — см. «Язык интерфейса» выше). Материалы заменяются собственными по любой теме обновлением этого списка (источник — rag/constants.py, config его реэкспортирует) и самих файлов в data/documents/; для Docker-образа имена файлов перечислены ещё и в COPY в Dockerfile.
Текстовый и RAG-режимы задаются системными промптами. Их можно слегка поправить под свою задачу:
| Файл | Назначение |
|---|---|
app/text_chat.py |
TUTOR_SYSTEM_PROMPT — промпт обычного текстового режима (сейчас: «You are a knowledgeable, patient learning assistant… Reply in the language the user is writing in…», без привязки к предмету). Замените текст константы на свою роль и инструкции. |
rag/query.py |
Промпты для режима RAG: описание роли и правил ответа по контексту (_generate_rag_response) и fallback, когда по базе ответа нет (_fallback_response) — оба тоже предметно-нейтральны и отвечают на языке пользователя. Подстройте формулировки под вашу тематику и стиль ответов. |
Менять код логики не обязательно — достаточно обновить тексты промптов и наполнить data/documents/ нужными материалами.
main.py— точка входа Telegram-бота (standalone-режим), подключение обработчиков, индексация RAG, инициализация/закрытие DB-движкаbot.py— экземпляр бота (pyTelegramBotAPI)web_main.py— точка входа web-адаптера (standalone-режим — не запускается одновременно сmain.pyпротив одного каталога Qdrant, см. «Единый процесс: Telegram + web»)service_main.py— единая точка входа Telegram + web в одном процессе — поддерживаемая production-топология, ровно один владелец локального Qdrantweb_config.py— настройки только для web-адаптера (SESSION_SECRET_KEYи др.), не требуется для Telegram-ботаconfig.py— настройки, пути, режимы (без Telegram-credential — см.telegram_config.py)telegram_config.py— валидацияTELEGRAM_BOT_TOKEN, импортируется только Telegram-адаптером (bot.py)handlers/— start, text, voice, image, document_upload (тонкие Telegram-адаптеры; резолвят внутренний UUID сразу после проверки доступа)web/— тонкий FastAPI-адаптер: app (фабрика приложения), routes (включаяPOST /api/link/telegram/start/POST /api/unlink/github), dependencies (централизованная проверка текущего пользователя/CSRF), cookies, csrf, schemas + github_oauth (GitHub OAuth login/callback) +POST /api/chat/GET/PATCH /api/settingsи body_limit (64 KiB-лимит тела этих запросов) +/api/documents*/POST /api/retrieval/search+ frontend (раздача собранного React-приложенияfrontend/dist: только/и/assets/...) — без бизнес-логики, весь резолвинг пользователя идёт черезapp/auth_session.py/app/github_identity.py/app/telegram_link.pyapp/— Telegram/Web-независимый прикладной слой: tutor (оркестрация диалога), session (состояние диалога — история/pending-image эфемерны в памяти, mode/voice — durable в PostgreSQL), documents (транзакция загрузки/индексации документа), identity (резолв Telegram id → внутренний UUID), auth_session (жизненный цикл серверной web-сессии: создание/резолв/отзыв, включая GitHub-race-safe выпуск), github_identity (резолв GitHub id → внутренний UUID), oauth_transaction (state/PKCE-транзакция GitHub-логина), text_chat (stateless текстовое ядро), preferences (единый резолвер эффективных mode/voice для Telegram и web — значения по умолчанию изBOT_MODE/DEFAULT_VOICE— и запись durable-режима для web безuser_sessions), telegram_link (генерация/хеширование bearer-секрета связывания, старт/redemption/отвязка; сырой секрет никогда не покидает этот модуль иweb/routes.py/handlers/start.py)db/— слой PostgreSQL: settings (DATABASE_URL, без credential-зависимостей), base/models (SQLAlchemy ORM), engine (ленивый sync-движок), identity (race-safe резолв/создание пользователя, чтение профиля по UUID, проверка наличия Telegram-привязки), preferences (mode/voice upsert), documents (каталог владения документами), auth_sessions (хранение web-сессий — только SHA-256 дайджест токена, включая race-safe GitHub-выпуск сессии), github_identity (race-safe резолв/создание пользователя по GitHub id), oauth_transactions (короткоживущая одноразовая OAuth-транзакция + database-authoritative admission control/rate limit), telegram_link (создание/redemption/отвязка попытки связывания под фиксированным порядком блокировок — см. «Связывание Telegram- и GitHub-аккаунтов»)frontend/— React + TypeScript (Vite) web-приложение: вход через GitHub, связывание аккаунтов, настройки, документы и текстовый чат;npm ci/npm run build— см. «Web-интерфейс (React)» выше;frontend/dist— сгенерированный каталог, не коммититсяgithub_oauth_config.py— настройки только для GitHub-логина (GITHUB_CLIENT_ID/SECRET/REDIRECT_URIи др.), не требуется для Telegram-ботаtelegram_link_config.py— настройки только для связывания Telegram/GitHub (TELEGRAM_BOT_USERNAMEи др.); никогда не импортируетTELEGRAM_BOT_TOKEN; отсутствие/некорректность значения не ломает запуск web-адаптераalembic/,alembic.ini— миграции схемы PostgreSQL (alembic upgrade head)services/— text_llm (провайдер-фасад), anthropic_client, openai_client, stt, tts, vision, image_generation, github_oauth_client (HTTP-клиент token exchange +/user)rag/— index (Qdrant, владение поowner_user_uuid), query, loader (PDF, TXT, MD, DOCX), identity (стабильные ID документов/чанков + валидация канонического UUID), sidecar (метаданные загруженных документов, схема v3)scripts/—rebuild_qdrant.py(полная переиндексация Qdrant из исходников),migrate_sidecars_v2_to_v3.py(разовый перевод legacy-сайдкаров с Telegram-id на внутренний UUID)utils/— logging, helpers (Telegram-утилиты: скачивание файлов, strip_markdown, очистка файлов), access_control (fail-closed allowlist по Telegram id)data/documents/— версионируемая база знаний RAG (.md, закоммичены) +uploads/(документы, загруженные пользователями через Telegram или web, в репозиторий не коммитятся)data/qdrant/— локальное хранилище Qdrant (в репозиторий не коммитится, полностью восстановимо черезpython -m scripts.rebuild_qdrant)data/generated_images/— сгенерированные DALL-E изображения.env.example— шаблон переменных окружения (локальная разработка)Dockerfile,docker-compose.prod.yml,.env.production.example— production-образ, Compose-стек (app + PostgreSQL за Traefik) и шаблон окружения для него;docs/PRODUCTION_DEPLOYMENT.md— runbook развёртывания.github/workflows/tests.yml— CI: backend (pytest + PostgreSQL) и frontend (lint, typecheck, tests, build)
Постоянное хранилище для канонической идентичности пользователей, привязки
Telegram-аккаунтов, настроек (mode/voice) и каталога владения документами.
Не используется как хранилище содержимого документов (это по-прежнему
физический файл + .meta.json) и не хранит историю переписки (она остаётся
эфемерной, в памяти процесса).
Таблицы (db/models.py, миграции в alembic/versions/, 0001–0005):
| Таблица | Назначение |
|---|---|
users |
Канонический внутренний пользователь (id UUID PK) |
telegram_accounts |
Привязка Telegram id → users.id (unique в обе стороны) |
user_preferences |
Сохранённая кастомизация mode/voice пользователя (durable); значения по умолчанию (BOT_MODE, DEFAULT_VOICE) в ней не хранятся — применяются при чтении |
documents |
Каталог владения загруженными документами (status: pending/active/deleting; deleting — маркер возобновляемого удаления, добавлен миграцией 0005; в списках, поиске и RAG участвуют только active) |
web_sessions |
Серверные web-сессии (alembic/versions/0002_web_sessions.py): session_token_hash (SHA-256 браузерного bearer-токена, PK) → users.id, expires_at, revoked_at |
github_accounts |
Привязка числового GitHub id → users.id (alembic/versions/0003_github_oauth.py), структурно как telegram_accounts |
github_oauth_transactions |
Короткоживущая одноразовая OAuth-транзакция GitHub-логина: state_hash (SHA-256 дайджест state, PK), code_verifier (PKCE), expires_at (индексирован для cleanup). Claim — атомарный DELETE ... RETURNING: заявленная/просроченная строка удаляется, а не помечается — таблица никогда не содержит «мёртвых» строк, и каждая существующая строка учитывается в потолке github_oauth_admission |
github_oauth_admission |
Singleton-строка (id=1) глобального admission control для /api/auth/github/login: window_start, starts_in_window — фиксированное 60-секундное окно rate limit, читается/обновляется под SELECT ... FOR UPDATE в той же транзакции, что cleanup+вставка новой OAuth-транзакции |
telegram_link_attempts |
Короткоживущая одноразовая попытка связывания Telegram/GitHub (alembic/versions/0004_telegram_link_attempts.py): web_user_id UUID PK (FK → users.id, ON DELETE RESTRICT — явно, не подразумеваемый) → link_secret_hash (SHA-256 дайджест bearer-секрета, UNIQUE, никогда не сырое значение), expires_at (индексирован). Не более одной строки на пользователя — повторный запрос атомарно заменяет предыдущий |
Схема создаётся ТОЛЬКО через Alembic — приложение не создаёт таблицы самостоятельно при старте:
alembic upgrade head
DATABASE_URL (пример в .env.example) использует диалект
postgresql+psycopg:// — один драйвер (psycopg v3) обслуживает и
синхронный, и асинхронный доступ. Импорт config.py/db/*.py/app/*.py
не требует TELEGRAM_BOT_TOKEN — только Telegram-адаптер (bot.py) и
всё, что от него зависит, требует валидный токен при старте.
Backend — из корня репозитория, в окружении с requirements.txt и
requirements-dev.txt (CI использует Python 3.12):
python -m pip check
python -m pytest
Тесты требуют PostgreSQL: задайте TEST_POSTGRES_DSN (так делает CI) либо
держите локально Docker с уже загруженным образом postgres:16-alpine.
С PYTEST_REQUIRE_POSTGRES=1 (как в CI) недоступный PostgreSQL — ошибка, а
не тихий пропуск тестов. Сеть pytest блокирует (разрешён только loopback).
Frontend — из frontend/, Node 24 LTS:
npm ci
npm run lint
npm run typecheck
npm test
npm run build
Именно эти команды выполняет .github/workflows/tests.yml.
Основные: pyTelegramBotAPI, openai, anthropic, qdrant-client, langchain-core, langchain-openai, langchain-community, langchain-text-splitters, pypdf, docx2txt, pydub, aiofiles, aiohttp, SQLAlchemy, psycopg, alembic, fastapi, uvicorn.
Подробный список: requirements.txt.
Логи пишутся в консоль и в файл bot.log. Для подробного вывода в .env укажите LOG_LEVEL=DEBUG.
Приложение развёрнуто на learn.elivcloud.org в Docker Compose за внешним Traefik, который терминирует HTTPS. Конфигурация развёртывания лежит в репозитории: Dockerfile, docker-compose.prod.yml, шаблон окружения .env.production.example и операционный runbook — docs/PRODUCTION_DEPLOYMENT.md (окружение, сеть Traefik, миграции, обновление, откат, backup/restore).
- Образ (
Dockerfile): multi-stage — frontend собирается на Node 24, runtime на Python 3.12 (сffmpegдля голосового режима), приложение работает под непривилегированным пользователем; команда запуска —python service_main.py; healthcheck —GET /healthz. - Сервисы (
docker-compose.prod.yml):postgres(postgres:16-alpine, только во внутренней сети, порт наружу не публикуется),app(единый процесс Telegram + web, порт 8000 доступен только через Traefik) и разовый сервисmigrate(профильtools). - Данные — именованные volumes:
postgres_data,qdrant_data(embedded Qdrant) иuploads_data(загруженные документы). Сгенерированные DALL-E изображения иbot.logне персистятся (логи — черезdocker compose logs). - Миграции никогда не запускаются автоматически: их применяют вручную перед запуском/обновлением
app(docker compose -f docker-compose.prod.yml --env-file ./.env.production --profile tools run --rm migrate). - Секреты и параметры — в
.env.production(в репозиторий не попадает, шаблон —.env.production.example); имя сети, entrypoint и certresolver Traefik обязательны и без значений по умолчанию. - Одна реплика: embedded Qdrant держит эксклюзивную блокировку каталога, поэтому масштабировать
appдо нескольких реплик нельзя.