Зачем вообще писать ещё одно приложение для изучения языка
Мой рабочий день - это код. Вечером я хочу разговаривать с кем-то по-английски, а не нажимать на пингвинчиков.
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 |
Миграции, эмбеддятся в бинарь через |
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/
Файл |
Размер |
Статус |
|---|---|---|
|
6.2K |
✅ готов, Anthropic Messages API |
|
6.6K |
✅ готов, Chat Completions + streaming |
|
7.1K |
✅ готов, Google Generative AI |
|
104 байта |
? заглушка. Честно: не дописал. В roadmap |
Юзер в настройках выбирает модель, фронт шлёт model_id в каждом запросе, handler достаёт провайдера из registry и вызывает.
Что я понял на этом якоре
Интерфейс должен покрывать минимум возможностей - три метода, и всё. Если добавлять «специфические» фичи каждого провайдера в интерфейс, он раздуется и сломается на четвёртом провайдере. Гемини и OpenAI поддерживают tool-calling по-разному - я просто не использую tool-calling в чате, и эта боль откладывается до момента, когда она реально понадобится.
Якорь №2: SSE для стриминга AI-ответов вместо WebSocket
Когда модель отвечает в чате, я хочу видеть текст по мере генерации, а не ждать 8 секунд блок целиком.
Очевидное решение - WebSocket.
Не очевидное, но правильное для моего кейса - Server-Sent Events.
Почему SSE, а не WS
Однонаправленный поток. AI-ответ идёт сервер → клиент. Юзер не пишет в этот канал. WebSocket для одностороннего стрима - оверкилл.
HTTP-инфраструктура. SSE работает поверх обычного HTTP/2, проходит через прокси, легко балансируется. WS требует отдельной обработки в nginx и балансировщиках.
Реконнект из коробки. Браузер сам переподключает SSE при разрыве с заголовком
Last-Event-ID. С WS это надо писать руками.Простота. 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,translationeasiness_factor(по умолчанию 2.5)interval_days,repetitionslast_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, открывают браузер, проходят флоу регистрации → создания сессии → ответа в чате.
Что не готово - честный список
Qwen-провайдер - заглушка 104 байта. Дописать - дело двух часов, но не было приоритета.
Голосовой режим - хочу диктовать ответы и слышать произношение. Web Speech API на фронте + ElevenLabs на бэке. В планах.
Импорт из Anki - юзеры с большими колодами не захотят начинать с нуля. Парсер
.apkgфайлов - в roadmap.Только 2 миграции -
usersиvocabulary. Это сразу выдаёт молодой проект. Будут ещё, когда добавлю темы (topics), повторяющиеся сессии (recurring_sessions) и группы слов (word_groups).Нет мобильного приложения - только веб. PWA достаточно, нативное iOS/Android - не в этом году.
Нет публичного хостинга - локальный запуск через
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.
Комментарии (11)

bimspecial
04.05.2026 05:37Классно, будет ли добавлена возможность устанавливать туда модель с openrouter или такие модели как deepseek, glm, minimax?

vdv007 Автор
04.05.2026 05:37Сейчас в коробке 4 провайдера, пока на все не хватает времени. Чтобы получить deepseek/glm/minimax - проще всего добавить openrouter-адаптер (он сам роутит на нужную модель). По сути это новый файл рядом с
claude_provider.go. Репо открыт - если опередите PR, буду рад.

rpc1
04.05.2026 05:37А не кажется что для этого этапа продукта Redis как раз overkill? можно было все это реализовать на Postgres, а не тащить дополнительный внешний сервис.

vdv007 Автор
04.05.2026 05:37Соглашусь с Вами и объясню свою позицию, на текущем масштабе - да, можно на Postgres. У меня Redis несёт две конкретные нагрузки: 1 JWT-blacklist с TTL = remaining-lifetime токена, +1 round-trip на каждом middleware - на pg это будет либо отдельная таблица с фоновым cleanup'ом, либо unlogged-таблица; 2 кеш - сколько слов сегодня к повторению, пересчитывается фоновой горутиной раз в день - это да, вообще можно как materialized view. Если бы делал заново на скоупе одного юзера - выбрал бы pg + LISTEN/NOTIFY. Redis оставил ,так скажем, на вырост - если когда-то многопользовательский сценарий пойдёт. Спасибо за обратную связь, мне это очень важно.

steus_au
04.05.2026 05:37Выглядит как интересный пет проект но оверкил. Простая промпт система и пара учебников даст примерно тот же выхлоп. Имхо

vdv007 Автор
04.05.2026 05:37У меня был немного другой кейс и тейк статьи. Lexis про ежедневный workflow с состоянием: где остановился, какие слова уже разбирали, какой уровень. В чате это каждый раз зашивать заново и есть та фрикция, от которой я устал. Плюс 4 провайдера - это страховка от просадки конкретной модели (кейс с новым Opus в апреле это было что то и я такого не ожидал). И это упражнение и в стек, и в архитектуру было моим личным желанием и заодно, по цене оверкила, получаешь язык + продукт под свою привычку + тренажёр. Если цель только язык - согласен, можно проще. Хорошего дня!

steus_au
04.05.2026 05:37да я не про чат, а про простой claude.md (или даже agents.md если opencode) в котором это будет прописано и любая модель сможет вам помочь с изучением языка.
вот мой пример по немецкому. работает вполне приемлемо даже с локальной gemma4-26b:
German - реактивация немецкого языка
Методология (evidence-based)
Comprehensible input (Krashen) на уровне i+1
Shadowing: слушай → повторяй → сравнивай
Spaced repetition (Anki на iPhone)
Pronunciation = feedback loop (сигнал → выход → сравнение → коррекция)
Регулярность > система. 15 мин/день > идеальная архитектура
Инструменты
edge-tts: de-DE-ConradNeural (муж), de-DE-KatjaNeural (жен). Путь: /opt/homebrew/bin/edge-tts
Anki на iPhone
RAG (mcp-local-rag): учебники проиндексировать когда начнём. Текущий embedding (all-MiniLM-L6-v2) EN only, может понадобиться multilingual
Аудио из учебников
edge-tts --voice "de-DE-ConradNeural" --file input.txt --write-media output.mp3edge-tts --voice "de-DE-ConradNeural" --rate "-30%" --file input.txt --write-media output.mp3 # медленнееЧто есть в папке
textbooks/ - 7 учебников (PDF, TXT): Collins Grammar, Collins Phrasebook, Practice Makes Perfect, 150 Short Stories и др.
audio/ - сгенерированные аудио-уроки
notes/ - заметки
What Claude should do
Генерировать аудио-уроки из глав учебников (текст → edge-tts)
Объяснять грамматику через примеры, не через правила
Разговорная практика: диалоги с переводом
Anki карточки (немецкий → русский + пример)
НЕ строить систему. Фокус на контент, не на архитектуру
Связанные файлы (в root)
~/opencode/notes/german.md - исходное обсуждение концепции
с японским по другому но не сильно сложнее - просто добавляется символьный уровень

vdv007 Автор
04.05.2026 05:37У Вас отличная стратегия обучения, тем более если она Вам подходит. Удачи Вам в реализации ваших планов!
Romatio
В свое время тоже решил запорочиться, но подтянуть разговорный.
Задается топик разговора, говоришь фразу, whisper распознает, передвает в llm, llm отвечает, и можно вести диалог. Есть подсветка ошибок, например, пропустил артикль или еще что с грамматикой.
Бысто уперся в лимит vram. 8 гиг мало.
vdv007 Автор
Это круто, у нас параллельные траектории. У Вас задача потяжелее по железу. Я обошёл это стороной: вместо локальной модели держу 4 провайдера через pluggable-интерфейс (claude/openai/gemini), переключаюсь по задаче. Latency страдает, зато ноут не плавится. А подсветка ошибок у Вас на правилах или whisper отдаёт уже размеченное? Если будете возвращаться к вопросу железа - расскажите как разрулите, мне интересно, может, потом у себя реализую.
Romatio
whisper только распознает, large-v3-turbo дает слабенькое качество, но с ним уже можно работать, модели меньше распознают отсебятину. Проверяет грамматику llm, сейчас qwen из новых в lm studio, thinking отключен. Как надоест мучать ноут, перенесу в облако.