Как устроен сервис
MathTrail стоит между чатом, которым семья уже пользуется, и Google Диском родителя. Каждую задачу пишет модель чата, а сервис решает, дойдёт ли она до ребёнка. Это одна программа: своих моделей она не вызывает и между запросами ничего не хранит.
- Один бинарник на Go в Cloud Run
- Без состояния
- Без базы данных
- Без своих вызовов LLM
- MCP 2026-07-28 · MCP Apps
Чат-клиент1 · клиент
- Claude
- ChatGPT
- другие хосты MCP Apps
Модель чата пишет задачу, карточка показывает её ребёнку.
MCP · вызовы инструментов по HTTPS
MathTrail в Google Cloud2 · среда выполнения
Cloud Run · один бинарник на Go · без состояния
Ядро — чистые вычисления
- Правило
- Рейтинги
- Проверки
- Решатель
- Контент
Вокруг него: OAuth, MCP-сервер, карточка, запечатывание, лимиты.
Drive API v3 · с собственным токеном родителя
Google Диск3 · хранилище
MathTrail/mathtrail-profile.json
Один файл на ребёнка, на Диске самого родителя, — единственное, что хранится долго.
01
Карта
Кто с кем говорит. Ни ребёнок, ни взрослый не обращаются к MathTrail напрямую — они говорят с чат-хостом. Всё в среднем блоке — части одного процесса, а не отдельные сервисы. Выберите любую часть, чтобы увидеть, что она делает и с чем связана.
02
Занятие, вызов за вызовом
Модель и карточка обращаются к одним и тем же инструментам и опираются на один источник правды — файл профиля на Диске родителя. Чтобы занятие шло, им не нужно ничего сообщать друг другу.
Задача
- Ребёнок
- Карточка
- Модель хоста
- MathTrail
- Диск
1Ребёнок → Модель хоста: «новую задачу, пожалуйста»
2Модель хоста → MathTrail: next_task
Хост рисует карточку, как только начинается вызов: «Готовим следующую задачу…»
лимиты · правило выбирает тему, уровень и сложность
3MathTrail → Диск: записать открытый запрос
4MathTrail → Модель хоста: запрос открыт — забери пакет
5Модель хоста → MathTrail: get_package
6MathTrail → Модель хоста: ТЗ · 3 эталонные задачи · ловушки · шаблоны · рамки
Модель пишет задачу, решатель на Starlark и самопроверку
7Модель хоста → MathTrail: submit_task — до 3 попыток
проверки · решатель должен найти ровно один верный вариант
8MathTrail → Диск: задача становится текущей, ответ запечатан
9Карточка → MathTrail: read_task — карточка спрашивает каждые 4 с
10MathTrail → Карточка: текст · рисунок · пять вариантов · подсказка — без ответа
Каждый вызов ещё и читает профиль с Диска — эти чтения опущены. Карточка, которая ждёт, пакета не видит: у эталонных задач в нём есть ответы. Пока ребёнок решает, модель пишет следующую задачу заранее (prepare_task), и когда ребёнок попросит другую, next_task сразу кладёт её на новую карточку — без этого ожидания.
Ответ
- Ребёнок
- Карточка
- Модель хоста
- MathTrail
- Диск
1Ребёнок → Карточка: нажимает C
2Карточка → MathTrail: submit_answer — ход чата не тратится
только первый ответ · печать вскрывается сейчас · θ и δ меняются только по верности
3MathTrail → Диск: записать рейтинги и историю
4MathTrail → Карточка: верно или нет · ловушка за C · решение · рейтинг до → после
5Карточка → Модель хоста: ui/update-model-context — одна строка о том, что произошло
6Ребёнок → Модель хоста: «почему не 6?» — вопрос в чате
7Модель хоста → Ребёнок: объясняет, начиная с ловушки
Нажатие записывает ответ раньше, чем что-либо объясняется. Если сообщение хоста модели потеряется, ребёнок всё равно увидит результат, а модель наверстает при следующем вызове.
Вход
- Браузер родителя
- Хост
- MathTrail
- Диск
1Хост → MathTrail: POST /mcp, без токена
2MathTrail → Хост: 401 · где лежат метаданные
3Хост → Браузер родителя: открывает /oauth/authorize · PKCE S256
4Браузер родителя → MathTrail: authorize
Свой экран согласия: какой клиент и куда он вернёт родителя
5Браузер родителя → Google: вход в Google · только drive.file
6Google → Браузер родителя: обратно, с кодом Google
7Браузер родителя → MathTrail: /oauth/callback
8MathTrail → Google: обменять код
9MathTrail → Диск: /about — чей это Диск?
id пользователя = HMAC аккаунта Диска · токены Google запечатаны в наш код
10MathTrail → Браузер родителя: 302 · code = mt1.c.…
11Браузер родителя → Хост: передаёт код обратно
12Хост → MathTrail: /oauth/token · код + verifier
13MathTrail → Хост: access 15 мин · refresh 30 дней
Родитель входит один раз. Сервис ничего из этого обмена не хранит: запрос едет в запечатанном state, токены Google — в запечатанном коде.
Текущая задача как конечный автомат
Каждое правило «только один раз» — это поле в профиле, которое проверяется внутри того же чтения, расчёта и записи. Больше ничто ничего не помнит.
nonenext_taskТЗ собраноrequestedsubmit_task ✓запечатана, выданаissuedsubmit_answerзаписан один разanswered
requested ↺отказ, попытки ещё есть — не больше трёх на запросrequested → noneтри отказа или 15 минут с открытия запроса; лимит задач не тратится, а три отказа считаются неудачным запросом дняissued → requested«Другая задача» до ответа — пропуск записан, рейтинг не тронутanswered ↺любой следующий ответ — из второй вкладки или от модели — возвращает первый результат и ничего не пишетanswered → requestedследующая задача, на новой карточке; отвеченная больше не текущаяanswered → issuedследующая задача написана заранее — next_task сразу кладёт её на новую карточку, минуя requestedissued → noneпечать уже не вскрыть — прошло две смены ключа; ничего не записано
03
Где живёт состояние
Всё, что сервер с состоянием держал бы в базе данных, хранит кто-то другой: в запечатанном токене, который держит чат, в cookie в браузере родителя или на Диске самого родителя. Перезапустите сервис или запустите вторую копию — ничего не потеряется.
Сам MathTrail
Ничего
Ни аккаунтов, ни сессий, ни регистраций, ни токенов Google. Каждый запрос несёт всё, что ему нужно.
Хранилище токенов чата
Запечатанные токены, непрозрачные для хоста
- Access-токен
- 15 мин
- Refresh-токен
- 30 дней, ≤ 90
Внутри запечатаны токены Google, непрозрачный id пользователя для лимитов и страна входа.
Браузер родителя
Две cookie, без сессии
- Одобренные клиенты
- 180 дней
- Защита входа (CSRF)
- 10 мин
Если их потерять — лишний экран согласия или вход заново.
Диск родителя
Единственное, что хранится долго
mathtrail-profile.json- пока не удалят
Профиль, рейтинги и текущая задача — с запечатанным ответом. Родитель может открыть файл, Диск хранит его историю.
Secret Manager
Читается один раз, при старте экземпляра
- Ключи запечатыванияпри смене ключа прежний ещё принимается
- 90 дней
- Секрет клиента Google
- закреплён
Память экземпляра
Только кэши: при перезапуске теряются, и ничего не ломается
- Счётчики частоты запросов
- минуты
- Где лежит файл профиля
- 10 мин
- Документы клиентов
- 5 мин – 24 ч
Нарочно нет в v1
- База данных
- Очереди
- Хранилище сессий
- Общий кэш
- Банк готовых задач
- Клиент LLM API
- REST и OpenAPI
04
Файл профиля
Один ребёнок — один JSON-файл: не кэш какой-то правды на сервере, а сама правда. У каждого блока есть потолок, который соблюдается при каждой записи, поэтому файл не растёт без предела: обычный укладывается в цель, а у потолков выходит за неё. Прошлые задачи хранятся как отпечатки, а не как текст.
Размер файла
≈ 61 КБ / ≈ 79 КБ
| Блок | Что в нём | Кто пишет | Размер | |
|---|---|---|---|---|
| Отпечатки | До 200 набросков выданных задач для проверки на почти-дубликаты — без текста, темы и даты | next_tasksubmit_task | 27 КБ / 29 КБ | |
| История рейтинга | Где были θ и δ темы перед каждым недавним ответом, а θ и все темы — в начале каждого из последних 7 дней с ответами | submit_answer | 14 КБ / 15 КБ | |
| Рейтинги и темы | θ и откуда ребёнок начал; по каждой теме: δ, ответы, ловушки, в которые он попадал, освоение и его уровень | next_tasksubmit_tasksubmit_answer | 8 КБ / 14 КБ | |
| Недавние ответы | Последние 20 ответов и пропусков — и никогда не меньше 5 ответов, которые читает правило | next_tasksubmit_answer | 4 КБ / 5 КБ | |
| Текущая задача, запечатанная | Ответ, ловушка за каждым неверным вариантом, решение и решатель — одна непрозрачная строка | next_tasksubmit_tasksubmit_answer | 4 КБ / 8 КБ | |
| Текущая задача, открытая | Что показывает карточка: текст, рисунок, пять вариантов, подсказку — и, после ответа, сам ответ | next_tasksubmit_tasksubmit_answer | 2 КБ / 4 КБ | |
| Открытый запрос | Пока задача пишется: её id, ТЗ, использованные попытки и кто выбрал тему | next_taskprepare_tasksubmit_task | 1,5 КБ / 2 КБ | |
| Сервис и ребёнок | Версия схемы, случайный UUID, ревизия; псевдоним, класс, интересы, заметки; дневные счётчики | save_profileedit_profileкаждая запись | 0,6 КБ / 1,5 КБ |
Задача, написанная заранее, лежит рядом с текущей и весит примерно столько же. Чего в файле нет: текстов прошлых задач, ответа в открытом виде, пока ребёнок не ответил, ничего, что называет родителя, и ранга — ранги вычисляются при показе. Условной записи у Диска нет, поэтому перед сохранением файл читается ещё раз, и запись отклоняется, если он изменился: две вкладки никогда молча не перезапишут друг друга.
05
Запечатано — и на сколько
Один механизм делает две работы: запечатывает вход в токены, которые держит чат, и ответ задачи — текущей и написанной заранее — в профиль. В каждую печать вшито её назначение, поэтому access-токен не выдать за refresh-токен, а запечатанный ответ — ни за что другое.
Устройство токена
mt1.a.kQ7fWx.5rJ0Yb2mQ8vK1nT4pXc9…
- mt1
- версия формата
- назначение
- c код · a access · r refresh · d клиент · s state · k согласие · t ответ задачи
- id ключа
- из самого ключа, поэтому смена ключа незаметна
- шифртекст
- XChaCha20-Poly1305, случайный 192-битный nonce
Не JWT: никто, кроме сервиса, его не читает, а из непрозрачной строки в лог не утечёт ни одно утверждение.
Сроки жизни, в логарифмической шкале
- Код авторизации
60 с - Вход в процессе
10 мин - Access-токен
15 мин - Refresh-токен
30 дней со сдвигом, не больше 90 - Ключ запечатывания
меняется каждые 90 дней - Одобренные клиенты
180 дней
Сервер без состояния не может пометить код использованным, поэтому окно для кода сделано маленьким. Прежний ключ после смены ещё принимается, поэтому ни токен, ни запечатанный ответ при смене ключа не пропадают.