Я устал от Duolingo и написал себе AI-репетитора. Go, Clean Architecture, 4 LLM-модели — и вот что из этого вышло. ai.. ai. Clean Architecture.. ai. Clean Architecture. Go.. ai. Clean Architecture. Go. jwt.. ai. Clean Architecture. Go. jwt. llm.. ai. Clean Architecture. Go. jwt. llm. modular monolith.. ai. Clean Architecture. Go. jwt. llm. modular monolith. Open source.. ai. Clean Architecture. Go. jwt. llm. modular monolith. Open source. pet-project.. ai. Clean Architecture. Go. jwt. llm. modular monolith. Open source. pet-project. refresh tokens.. ai. Clean Architecture. Go. jwt. llm. modular monolith. Open source. pet-project. refresh tokens. SSE.. ai. Clean Architecture. Go. jwt. llm. modular monolith. Open source. pet-project. refresh tokens. SSE. изучение английского.. ai. Clean Architecture. Go. jwt. llm. modular monolith. Open source. pet-project. refresh tokens. SSE. изучение английского. Изучение языков.. ai. Clean Architecture. Go. jwt. llm. modular monolith. Open source. pet-project. refresh tokens. SSE. изучение английского. Изучение языков. искусственный интеллект.. ai. Clean Architecture. Go. jwt. llm. modular monolith. Open source. pet-project. refresh tokens. SSE. изучение английского. Изучение языков. искусственный интеллект. Программирование.

Зачем вообще писать ещё одно приложение для изучения языка

Мой рабочий день – это код. Вечером я хочу разговаривать с кем-то по-английски, а не нажимать на пингвинчиков.

  • Duolingo учит меня заказывать яблоки в магазине.

  • Memrise превратился в видеоплатформу с озвучкой.

  • ChatGPT-чат отлично объясняет грамматику, но не помнит, что я уже разбирал Present Perfect в среду и опять путаю его с Past Simple в пятницу.

Я хотел простую штуку: написать модели «давай сегодня про багтрекеры», получить чат на 15 минут, а в конце – три новых слова, которые она же мне и подобрала по уровню B1. Чтобы завтра эти слова всплыли в упражнениях. Чтобы статистика показывала, что я реально продвинулся, а не залип на стрике.

Такого продукта в моём публичном поиске не нашлось. Самописные «AI-tutor» в основном – обёртка над OpenAI API без памяти и без структуры. Я разработчик, у меня есть Go, Postgres, Redis и пара выходных. Через месяц получился Lexis – приложение с MIT-лицензией, четырьмя режимами тренировок и pluggable AI-провайдерами, которое теперь живёт у меня локально.

Это не история про «как заработать на edtech». Это инженерная история про то, как написать рабочий продукт с архитектурой, которая не развалится, когда я через год захочу добавить голосовой режим.

Дальше – три технических якоря, которыми я доволен, и честный список того, что ещё не готово.


Архитектура: модульный монолит, четыре модуля, Clean Arch внутри каждого

Версия 0.10.0 на момент записи статьи, репозиторий github.com/VDV001/lexis, MIT-лицензия.

Стек – короткий и без экзотики

Технология

Версия

Зачем

Go

1.26.1

Не 1.21, потому что писал в апреле 2026 и хотелось свежие generics-улучшения

chi

v5.2.5

Минимальный роутинг, прозрачный, без магии

PostgreSQL + pgx

v5.9.1

Основная БД

golang-migrate

v4.19.1

Миграции, эмбеддятся в бинарь через embed.FS

Redis

v9.18.0

Blacklist-токенов и кеширование

sqlc

Типобезопасный SQL без ORM-абстракций

JWT

v5.3.1

Симметричный HS256, ниже расскажу про rotation

zerolog + viper

Логи и конфиг

testify + gomock

v1.11.1

Юнит-тесты

Структура внутри каждого модуля

Классическая Clean Architecture:

  • domain – интерфейсы и модели

  • usecase – бизнес-логика

  • handler – HTTP-обработчики

  • infra – адаптеры к БД, Redis, внешним API

Между модулями

In-memory EventBus с интерфейсом, чтобы потом подменить на Kafka, когда (и если) понадобится. Сейчас бас отправляет события вроде WordLearned, SessionCompleted, StreakBroken – их слушает модуль progress, чтобы пересчитать аналитику без прямой связности с vocabulary.

Почему именно так

Это сознательный выбор:

  • Микросервисы для пет-проекта на одного юзера – оверинжиниринг.

  • Монолит, который через год нельзя распилить, – тоже путь в никуда.

  • Модульный монолит с границами на уровне пакетов и шиной событий даёт обе опции: сейчас один процесс и один Postgres, потом – выделить любой модуль в отдельный сервис без переписывания.


Якорь №1: pluggable AI-провайдеры через интерфейс

Изначально хотел только Claude. Потом подумал: если я буду тестировать упражнения, мне нужно сравнивать модели. И вообще – привязка к одному вендору в 2026 году выглядит наивно.

Каждый провайдер – отдельный файл в tutor/infra/

Файл

Размер

Статус

claude_provider.go

6.2K

✅ готов, Anthropic Messages API

openai_provider.go

6.6K

✅ готов, Chat Completions + streaming

gemini_provider.go

7.1K

✅ готов, Google Generative AI

qwen_provider.go

104 байта

🚧 заглушка. Честно: не дописал. В roadmap

Юзер в настройках выбирает модель, фронт шлёт model_id в каждом запросе, handler достаёт провайдера из registry и вызывает.

Что я понял на этом якоре

Интерфейс должен покрывать минимум возможностей – три метода, и всё. Если добавлять «специфические» фичи каждого провайдера в интерфейс, он раздуется и сломается на четвёртом провайдере. Гемини и OpenAI поддерживают tool-calling по-разному – я просто не использую tool-calling в чате, и эта боль откладывается до момента, когда она реально понадобится.


Якорь №2: SSE для стриминга AI-ответов вместо WebSocket

Когда модель отвечает в чате, я хочу видеть текст по мере генерации, а не ждать 8 секунд блок целиком.

  • Очевидное решение – WebSocket.

  • Не очевидное, но правильное для моего кейса – Server-Sent Events.

Почему SSE, а не WS

  1. Однонаправленный поток. AI-ответ идёт сервер → клиент. Юзер не пишет в этот канал. WebSocket для одностороннего стрима – оверкилл.

  2. HTTP-инфраструктура. SSE работает поверх обычного HTTP/2, проходит через прокси, легко балансируется. WS требует отдельной обработки в nginx и балансировщиках.

  3. Реконнект из коробки. Браузер сам переподключает SSE при разрыве с заголовком Last-Event-ID. С WS это надо писать руками.

  4. Простота. SSE-обработчик в Go – 30 строк, WS – 100+ с обработкой ping/pong, контролем frame size, закрытием соединения.

Единственный минус

SSE поверх HTTP/1.1 ограничен 6 одновременными соединениями на домен. Для одиночного приложения это не проблема, для прода с тысячами юзеров – перейти на HTTP/2, где лимит 100.


Якорь №3: JWT с rotation и reuse detection

Это часть, на которую ушло больше всего времени и которой я больше всего горжусь. Большинство туториалов по JWT в Go останавливаются на «проверь подпись и таймстемп». Это не работает в проде.

Проблема

Если refresh-токен утёк, злоумышленник может получать новые access-токены вечно. Как понять, что токен утёк? Только если жертва однажды попытается использовать тот же refresh-токен после злоумышленника.

Решение: token rotation + reuse detection

Реализовано в auth/usecase/auth_service.go:138-190. Логика:

1. Login Юзер получает access-токен (15 минут) и refresh-токен (30 дней). Refresh-токен записывается в БД с полем family_id и used = false.

2. Refresh через /auth/refresh Бэк проверяет:

  • Подпись валидна.

  • Токен не в Redis-blacklist.

  • В БД used = false.

3. Если всё ок Помечаем старый refresh used = true, выдаём новую пару с тем же family_id. Старый access добавляется в Redis blacklist до своего истечения.

4. Если refresh уже used = true – REUSE Значит, кто-то его уже использовал. Реакция: вызываем RevokeAllForUser(userID, familyID) – инвалидируем всю семью токенов и все access-токены этого юзера.

Юзер вылетает на логин на всех устройствах. Это плохо для UX, но правильно для безопасности: если токен утёк, лучше пять минут раздражения, чем неделя кражи данных.

Race condition

Между GetRefreshToken и MarkRefreshUsed решается транзакцией с SELECT ... FOR UPDATE. Это важно: без блокировки строки два одновременных refresh-запроса могут оба пройти проверку Used == false, оба получат новые токены, и reuse detection не сработает.

Redis-blacklist

Через infra/redis_blacklist.go хранит JTI инвалидированных access-токенов с TTL равным оставшемуся времени жизни токена. Каждый middleware проверяет blacklist – +1 round-trip к Redis на запрос, но это компромисс между security и latency, который я готов платить.

В сумме файл auth_service.go – 230 строк, и это честный production-ready код. Не «на потом перепишем», а то, что я сам ставлю на свои данные.


SM-2 spaced repetition: словарь, который сам подбирает повторения

В версии 0.10.0 модуль vocabulary хранит слова юзера в Postgres со следующими полями:

  • word, translation

  • easiness_factor (по умолчанию 2.5)

  • interval_days, repetitions

  • last_reviewed_at, next_review_at

Quality

Оценка от 0 до 5, как юзер вспомнил слово.

  • Плюс алгоритма – он реально работает, проверено десятилетиями Anki.

  • ⚠️ Минус – юзеру надо честно отвечать на quality, иначе кривая повторений сломается.

Каждый день фоновая горутина с time.Ticker пересчитывает «сколько слов сегодня к повторению» и кеширует это в Redis. Без кеша на каждый заход в дашборд был бы запрос в Postgres с фильтром next_review_at <= NOW() – не катастрофа, но лишняя нагрузка.

Четыре режима тренировки

Режим

Что делает

Откуда слова

Квиз

Выбор перевода из 4 вариантов

Из «к повторению сегодня»

Перевод

Юзер пишет перевод текстом, AI оценивает

Обновляет SM-2 quality

Заполнение пропусков

AI генерирует предложение с пропуском

Слово из своего словаря

Составление слов

Буквы перемешаны, надо собрать

Простой режим для орфографии


Тесты: testify, gomock, Playwright

Принцип: ATDD-цикл. Acceptance-тест (Playwright e2e) пишется первым, падает. Юнит-тесты внутри слоёв пишутся, чтобы acceptance прошёл.

  • testify v1.11.1 – assertions и suites. assert.Equal, require.NoError, suite.Suite.

  • go.uber.org/mock – мокаем интерфейсы доменного слоя. Например, mocks/mock_ai_provider.go для интерфейса AIProvider – usecase-тесты не вызывают реальный Anthropic API.

  • Playwright e2e на TypeScript – запускают приложение в Docker, открывают браузер, проходят флоу регистрации → создания сессии → ответа в чате.


Что не готово – честный список

  1. Qwen-провайдер – заглушка 104 байта. Дописать – дело двух часов, но не было приоритета.

  2. Голосовой режим – хочу диктовать ответы и слышать произношение. Web Speech API на фронте + ElevenLabs на бэке. В планах.

  3. Импорт из Anki – юзеры с большими колодами не захотят начинать с нуля. Парсер .apkg файлов – в roadmap.

  4. Только 2 миграцииusers и vocabulary. Это сразу выдаёт молодой проект. Будут ещё, когда добавлю темы (topics), повторяющиеся сессии (recurring_sessions) и группы слов (word_groups).

  5. Нет мобильного приложения – только веб. PWA достаточно, нативное iOS/Android – не в этом году.

  6. Нет публичного хостинга – локальный запуск через docker compose up. Деплоить мульти-юзер сервис с биллингом за LLM-токены – отдельный проект, и пока не моя цель.


Попробовать

git clone https://github.com/VDV001/lexis
# вписать AnthropicKey / OpenAIKey / GoogleKey хотя бы один
docker compose up -d
# фронт: http://localhost:3000
# бэк:   http://localhost:8080

В .env нужны:

  • ключ хотя бы одного AI-провайдера

  • JWT_SECRET (любой длинный рандомный)

  • DB_DSN (по умолчанию работает с docker-compose)

  • REDIS_ADDR (тоже по умолчанию)

Регистрация – email + пароль. Никаких внешних OAuth, я не хотел зависеть от чужой аутентификации. Bcrypt для хеширования, минимум 8 символов.

После регистрации – выбор языка (English), уровня (A1-C2), темы недели. Создаётся первая сессия, и можно писать модели.


Вместо вывода

Lexis как продукт – он мой личный, я им пользуюсь. Эта статья – про инженерные решения, которые мне нравятся и которые я бы рекомендовал в любом своём следующем проекте:

  • Модульный монолит с готовностью к распилу.

  • Pluggable провайдеры через минимальный интерфейс.

  • SSE вместо WebSocket там, где поток однонаправленный.

  • JWT rotation + reuse detection как стандарт, а не «может потом».

Если у вас есть вопросы по архитектуре или вы видите спорные решения – GitHub Issues открыты, MIT-лицензия позволяет форкать без вопросов. Если вы тоже устали от пингвинов и хотите AI-репетитора, который помнит, что вы вчера разбирали – попробуйте.

Репозиторий: github.com/VDV001/lexis Лицензия: MIT


P.S.

Если статья зашла – поставьте плюс, и я напишу разбор отдельных частей: например, про настройку Playwright для Go-бэкенда или про то, как я писал систему промптов для четырёх режимов упражнений на трёх разных моделях и они отвечают примерно одинаково.

Скриншоты будут в проекте в директории screenshots.

Автор: vdv007

Источник