Skip to content

About

Multimodal learning assistant with web and Telegram interfaces, private RAG, document workflows, GitHub OAuth, PostgreSQL, Qdrant, and production deployment.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

41 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Multimodal Learning Assistant

Личный 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-развёртывание».

Запуск

  1. Скопируйте .env.example в .env и заполните:
    • TELEGRAM_BOT_TOKEN — токен от @BotFather
    • OPENAI_API_KEY — ключ OpenAI (обязателен всегда: embeddings, Vision, STT, TTS, генерация изображений)
    • LLM_PROVIDER — провайдер основного текстового диалога/RAG-ответов: anthropic (по умолчанию) или openai
    • ANTHROPIC_API_KEY — ключ Anthropic (обязателен, если LLM_PROVIDER=anthropic)
    • TELEGRAM_ALLOWED_USER_IDS — числовые Telegram user id, кому разрешён доступ (через запятую)
    • DATABASE_URL — строка подключения к PostgreSQL (см. раздел «PostgreSQL» ниже)
  2. Установите зависимости: pip install -r requirements.txt
  3. Поднимите PostgreSQL (локально или в контейнере) и примените миграции: alembic upgrade head
  4. Запуск (только 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 — никогда как замена проверке доступа.


Web-адаптер

Помимо Telegram-бота, репозиторий содержит отдельный FastAPI-адаптер (web/) поверх того же канонического слоя идентичности — без второй модели пользователя и без второго источника истины. Аутентификация браузера — настоящая серверная сессия в PostgreSQL (не JWT, не подписанный клиентский токен): cookie несёт только непрозрачный криптографически случайный идентификатор, в базе хранится лишь его SHA-256-дайджест (см. db/auth_sessions.py, app/auth_session.py).

  • Запуск как отдельный процесс (не запускает Telegram-бота) — подходит для web-only развёртывания или локальной работы только над web/:
    python web_main.py
    
    Требует SESSION_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 остаётся дополнительным слоем, не основным механизмом.

GitHub OAuth-логин

Браузер может дополнительно войти через настоящий 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, получив проверенный id GitHub-аккаунта (и ДО того, как для него будет создана строка github_accounts/users или выпущена сессия), сверяет его с GITHUB_ALLOWED_USER_IDS (utils/github_access_control.py) — тем же fail-closed списком по формату/поведению, что и TELEGRAM_ALLOWED_USER_IDS для Telegram (см. «Доступ к боту и идентификация пользователей» выше), но полностью отдельным: пусто/не задано = вход запрещён всем, а не "всем разрешено". Неавторизованная попытка получает общий 403 и не оставляет следа в таблицах идентичности (ни github_accounts, ни users).
  • Устойчивый внешний идентификатор — числовой id GitHub-аккаунта (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 + PKCE code_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 сбрасывается только когда query state совпал с ней (эта транзакция действительно текущая для браузера) — несовпадающий/старый 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.

Ограничение хранилища OAuth-транзакций и глобальный rate limit

Каждый GET /api/auth/github/login — неаутентифицированный маршрут. Чтобы он не мог неограниченно расти в PostgreSQL, db.oauth_transactions. create_sync() выполняет ВСЁ следующее одной атомарной транзакцией, сериализованной между процессами через SELECT ... FOR UPDATE над singleton-строкой github_oauth_admission (тот же приём, что web_session_policy уже использует для политики cookie web-сессий):

  1. удаляет все просроченные строки github_oauth_transactions (индекс ix_github_oauth_transactions_expires_at);
  2. сбрасывает/продлевает фиксированное 60-секундное окно глобального rate limit (GITHUB_OAUTH_MAX_STARTS_PER_MINUTE, по умолчанию 30 стартов/минуту — глобально, не по IP: у приложения пока нет доверенного контракта на IP клиента за прокси);
  3. отклоняет запрос (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 мерой (см. ниже), но не единственной защитой хранилища.

Приватность callback (обязательное production-требование)

GET /api/auth/github/callback получает code/state в query-строке — это, по определению OAuth Authorization Code, чувствительные одноразовые креденшлы. Уровни защиты:

  • Код-уровневая гарантия (реализована и протестирована сейчас). web_main.py запускает Uvicorn с access_log=False — access-лог с query-строкой никогда не пишется этим процессом. web/app.py's create_app() дополнительно отключает логгер "uvicorn.access" (defense-in-depth) на случай запуска через голый uvicorn CLI/конфиг (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-уровневого предела, никогда не замена ему.

Связывание Telegram- и GitHub-аккаунтов

Аутентифицированный web-пользователь (вошедший через GitHub) может явно связать свой аккаунт с существующим Telegram-аккаунтом того же человека — после этого канонический пользователь один: users.id Telegram-стороны (Telegram UUID переживает связывание всегда — именно он остаётся владельцем всех документов/настроек/RAG-данных, а не GitHub-сторона).

  • Как это работает для пользователя:
    1. На сайте (после входа через GitHub) — POST /api/link/telegram/start (валидная сессия + CSRF) возвращает диплинк вида https://t.me/<bot_username>?start=link_<секрет> и время его истечения.
    2. Пользователь открывает диплинк в Telegram и нажимает Start — существующий allowlist-гейт (utils.access_control) и резолв Telegram UUID отрабатывают как обычно, ДО обработки link_....
    3. Бот отвечает одним сообщением: успех, «уже связано» или общая ошибка (без деталей — исключает перебор состояния аккаунта).
    4. После успешного связывания существующие web-сессии GitHub-стороны отзываются (не переносятся) — необходим повторный вход через GitHub; новая сессия резолвится уже в переживший Telegram UUID.
  • Секрет — 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.

Web-чат и настройки

Все эндпоинты требуют валидную серверную сессию (иначе 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"} без эха введённых данных.

Web-документы

Документы принадлежат пользователю: идентификатор берётся только из сессии, клиентского идентификатора нет. Документ виден и удаляется только владельцем — чужой и несуществующий id дают один и тот же 404. Правила сессии и CSRF — те же, что выше (изменяющие запросы — с X-CSRF-Token).

  • GET /api/documents?limit=&offset= — каталог активных документов пользователя (limit 1–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.

Единый процесс: 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 с новой установленной реализацией.

Web-интерфейс (React)

Каталог 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 — в разделе «Проверки» ниже.

Локальная разработка (два процесса)

  1. В .env для локальной разработки (только так cookie без Secure допускаются — см. web_config.py):
    WEB_ENV=development
    WEB_COOKIE_SECURE=false
    GITHUB_REDIRECT_URI=http://127.0.0.1:5173/api/auth/github/callback
    
    В настройках dev-OAuth-приложения GitHub «Authorization callback URL» должен быть точно таким же. Callback URL — это origin Vite, а не backend: браузер работает только с ним, а /api/... проксируется дальше.
  2. Backend: python web_main.py (или python service_main.py) — слушает 127.0.0.1:8000.
  3. 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 (требования выше остаются в силе).

Production: тот же процесс, тот же origin

Отдельного 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, а не тематика или язык ответов ассистента (см. «Язык интерфейса» выше). Чтобы полностью перейти на свою тематику (другой язык программирования, предмет, корпоративная база знаний, поддержка и т.п.), обычно достаточно двух вещей:

1. База знаний (RAG)

  • Встроенная база знаний — ровно четыре версионируемых файла в 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.

Векторное хранилище (Qdrant)

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 скрипта):
    python -m scripts.rebuild_qdrant            # dry-run: только проверка и подсчёт, без изменений
    python -m scripts.rebuild_qdrant --apply     # реальная переиндексация (не разрушает существующий индекс: сверяет с источником, недостающее/изменившееся переиндексирует, лишнее удаляет только после успеха всех документов)
    
    Dry-run (без --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) сайдкаров
    

Встроенный стартовый набор материалов (Python)

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.

2. Системные промпты

Текстовый и 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-топология, ровно один владелец локального Qdrant
  • web_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.py
  • app/ — 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)

PostgreSQL

Постоянное хранилище для канонической идентичности пользователей, привязки 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.

Production-развёртывание

Приложение развёрнуто на 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 до нескольких реплик нельзя.

About

Multimodal learning assistant with web and Telegram interfaces, private RAG, document workflows, GitHub OAuth, PostgreSQL, Qdrant, and production deployment.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages