MathTrail

Как устроен сервис

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 напрямую — они говорят с чат-хостом. Всё в среднем блоке — части одного процесса, а не отдельные сервисы. Выберите любую часть, чтобы увидеть, что она делает и с чем связана.

Части сервиса

Чат семьи

Claude в браузере, на компьютере и телефоне · ChatGPT

Люди

Чат-хост

MathTrail

Один бинарник на Go · Cloud Run · свой домен

между частями — обычные вызовы функций

Вход

Инструменты — прочитать профиль, посчитать, записать обратно

Чистые вычисления — без сети

Вокруг ядра

Google и GitHub

Единственные сервисы вне бинарника

Google-аккаунт родителя

Google Cloud

GitHub

Выберите часть карты: всё, с чем она связана, останется ярким, остальное потускнеет.

Чат семьи

Взрослый

Родитель или репетитор. Владеет и аккаунтом чата, и Google-аккаунтом: подключает MathTrail, один раз входит, создаёт профиль и следит за прогрессом. Единственный, кто входит в аккаунт.

Чат семьи

Ребёнок

1–6 класс. Решает задачи на карточке, в чате взрослого. Своего входа у него нет.

Чат-хост

Модель хоста

Claude или ChatGPT. Пишет задачу, решатель на Starlark, объяснение за каждым неверным вариантом и самопроверку; ведёт занятие словами там, где карточки не рисуются. Сам MathTrail моделей не вызывает.

Чат-хост

Среда MCP Apps

Рисует карточку в изолированной рамке, передаёт ей язык, платформу и размер и пересылает собственные вызовы карточки с токеном хоста — сама карточка токена никогда не видит.

Чат-хост

Документ метаданных клиента

Хост называет себя HTTPS-адресом. MathTrail загружает документ по этому адресу под защитой от SSRF, вместо того чтобы вести реестр клиентов.

MathTrail · вход

HTTP-маршрутизатор

Единственный вход: /health, /mcp, точки OAuth и .well-known. Тайм-ауты, ограничение размера тела, проверка Origin; издатель — собственный домен, на любой другой Host ответ 404.

MathTrail · вход

Сервер авторизации

MathTrail — сам себе сервер OAuth 2.1 с PKCE: метаданные, свой экран согласия, вход через Google, токены, обновление и отзыв. Всё, что хранил бы сервер с состоянием, запечатано в токены и cookie.

MathTrail · вход

MCP-сервер

Протокол: Streamable HTTP 2026-07-28, без состояния, на официальном Go SDK. Перечисляет и вызывает инструменты, отдаёт карточку как ресурс ui:// и передаёт модели инструкции с их версией.

MathTrail · вход

Лимиты

Темп по адресу до входа, по аккаунту после него, общий потолок — всё в памяти, — и дневные потолки, которые считаются в профиле. Сверх лимита — простая фраза, которую модель передаст.

MathTrail · инструменты

Инструменты

Одиннадцать инструментов одного вида: прочитать профиль, посчитать, записать обратно. Большинство вызывает модель; карточка вызывает свои — задачу, ответ, прогресс, форму профиля. В результат никогда не попадает ничего секретного.

MathTrail · чистые вычисления

Правило

Детерминированно выбирает ТЗ: после ошибки — закрепить ту же тему, иначе — тему, которой не было дольше всех; сложность — из коридора, где шанс успеха 70–85 %.

MathTrail · чистые вычисления

Рейтинги

P = 0,2 + 0,8·σ(θ + δ − β). Эло обновляется только по верности ответа, с затухающим шагом; шахматная шкала и одиннадцать рангов; тема считается освоенной по осторожной оценке.

MathTrail · чистые вычисления

Проверки

Что должна пройти присланная задача: структура и пять разных вариантов, буквы языка занятия, разные объяснения, решатель, самопроверка, читаемость, почти-дубликаты, текстовый рисунок. О каждой неудаче сообщается сразу.

MathTrail · чистые вычисления

Решатель

Запускает программу модели на Starlark, которая перебирает варианты; верным должен выйти ровно один. Лимиты шагов и времени, без сети и файлов.

MathTrail · чистые вычисления

Контент

Всё, что встроено в бинарник и не является кодом: каталоги тем, ловушек и навыков, эталонные задачи, схемы, шаблоны решателей, рамки рисунков и инструкции модели с их версией.

MathTrail · вокруг ядра

Виджет

Карточка: Preact, собранный в один HTML-файл без внешних загрузок, словари языков встроены. Вёрстка работает от 320 px, кнопки — под палец.

MathTrail · вокруг ядра

Хранилище

Находит профиль по скрытой метке, сравнивает перед записью, мирит две вкладки, по просьбе родителя восстанавливает файл из закреплённых ревизий Диска — и укладывается в небольшой бюджет вызовов Диска.

MathTrail · вокруг ядра

Запечатывание

Один механизм, две работы: запечатывает вход и токены Google в токены, которые держит хост, а ответ задачи — текущей и написанной заранее — в профиль. Ключи берутся из Secret Manager и сменяются по id ключа.

MathTrail · вокруг ядра

Журналирование

Одна структурированная сводка на вызов: инструмент, исход, длительность, версия инструкций; попытки и причины отказа — отдельными событиями задачи. Без личных данных, без текста задач, без ответов.

MathTrail · вокруг ядра

Контейнер и конфигурация

Связывает части и закрывает их в обратном порядке, работает с тайм-аутами и мягкой остановкой, всю конфигурацию читает из окружения — и не запускается в Cloud Run с флагами разработки.

Google-аккаунт родителя

Google OAuth

Вход родителя только с областью drive.file, обновление и отзыв. Отзыв любого токена прекращает весь доступ.

Google-аккаунт родителя

Drive API v3

Единственное долговременное хранилище: один JSON-профиль в видимой папке MathTrail на Диске родителя, читается и пишется собственным токеном родителя.

Google Cloud

Secret Manager

Ключи запечатывания и секрет клиента Google: закреплённые версии, читаются один раз при старте экземпляра.

Google Cloud

Artifact Registry

Образ каждой ревизии, по дайджесту; старые образы удаляются.

Google Cloud

Cloud Logging

Куда попадает stdout сервиса. Строки, по которым считаются дети, хранятся 62 дня в отдельном бакете.

Google Cloud

BigQuery → Data Studio

Счётчики использования хранятся годами и пересчитываются каждую ночь; ни в одной таблице нет имени, под которым считается ребёнок. Закрытый отчёт и публичный — без групп меньше десяти детей.

GitHub

Actions

Сборка, проверки, публикация образа и деплой через Workload Identity Federation — без ключей сервисных аккаунтов.

GitHub

Pages

Этот сайт: страницы тем, политика конфиденциальности и условия, на которые ссылается экран согласия.

02

Занятие, вызов за вызовом

Модель и карточка обращаются к одним и тем же инструментам и опираются на один источник правды — файл профиля на Диске родителя. Чтобы занятие шло, им не нужно ничего сообщать друг другу.

Сценарий
  • вызов
  • его ответ
  • внутри хоста

Задача

  • Ребёнок
  • Карточка
  • Модель хоста
  • MathTrail
  • Диск
  1. 1Ребёнок → Модель хоста: «новую задачу, пожалуйста»

  2. 2Модель хоста → MathTrail: next_task

  3. Хост рисует карточку, как только начинается вызов: «Готовим следующую задачу…»

  4. лимиты · правило выбирает тему, уровень и сложность

  5. 3MathTrail → Диск: записать открытый запрос

  6. 4MathTrail → Модель хоста: запрос открыт — забери пакет

  7. 5Модель хоста → MathTrail: get_package

  8. 6MathTrail → Модель хоста: ТЗ · 3 эталонные задачи · ловушки · шаблоны · рамки

  9. Модель пишет задачу, решатель на Starlark и самопроверку

  10. 7Модель хоста → MathTrail: submit_task — до 3 попыток

  11. проверки · решатель должен найти ровно один верный вариант

  12. 8MathTrail → Диск: задача становится текущей, ответ запечатан

  13. 9Карточка → MathTrail: read_task — карточка спрашивает каждые 4 с

  14. 10MathTrail → Карточка: текст · рисунок · пять вариантов · подсказка — без ответа

Каждый вызов ещё и читает профиль с Диска — эти чтения опущены. Карточка, которая ждёт, пакета не видит: у эталонных задач в нём есть ответы. Пока ребёнок решает, модель пишет следующую задачу заранее (prepare_task), и когда ребёнок попросит другую, next_task сразу кладёт её на новую карточку — без этого ожидания.

Ответ

  • Ребёнок
  • Карточка
  • Модель хоста
  • MathTrail
  • Диск
  1. 1Ребёнок → Карточка: нажимает C

  2. 2Карточка → MathTrail: submit_answer — ход чата не тратится

  3. только первый ответ · печать вскрывается сейчас · θ и δ меняются только по верности

  4. 3MathTrail → Диск: записать рейтинги и историю

  5. 4MathTrail → Карточка: верно или нет · ловушка за C · решение · рейтинг до → после

  6. 5Карточка → Модель хоста: ui/update-model-context — одна строка о том, что произошло

  7. 6Ребёнок → Модель хоста: «почему не 6?» — вопрос в чате

  8. 7Модель хоста → Ребёнок: объясняет, начиная с ловушки

Нажатие записывает ответ раньше, чем что-либо объясняется. Если сообщение хоста модели потеряется, ребёнок всё равно увидит результат, а модель наверстает при следующем вызове.

Вход

  • Браузер родителя
  • Хост
  • MathTrail
  • Google
  • Диск
  1. 1Хост → MathTrail: POST /mcp, без токена

  2. 2MathTrail → Хост: 401 · где лежат метаданные

  3. 3Хост → Браузер родителя: открывает /oauth/authorize · PKCE S256

  4. 4Браузер родителя → MathTrail: authorize

  5. Свой экран согласия: какой клиент и куда он вернёт родителя

  6. 5Браузер родителя → Google: вход в Google · только drive.file

  7. 6Google → Браузер родителя: обратно, с кодом Google

  8. 7Браузер родителя → MathTrail: /oauth/callback

  9. 8MathTrail → Google: обменять код

  10. 9MathTrail → Диск: /about — чей это Диск?

  11. id пользователя = HMAC аккаунта Диска · токены Google запечатаны в наш код

  12. 10MathTrail → Браузер родителя: 302 · code = mt1.c.…

  13. 11Браузер родителя → Хост: передаёт код обратно

  14. 12Хост → MathTrail: /oauth/token · код + verifier

  15. 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 сразу кладёт её на новую карточку, минуя requested
  • issued → 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_task27 КБ / 29 КБ
История рейтингаГде были θ и δ темы перед каждым недавним ответом, а θ и все темы — в начале каждого из последних 7 дней с ответамиsubmit_answer14 КБ / 15 КБ
Рейтинги и темыθ и откуда ребёнок начал; по каждой теме: δ, ответы, ловушки, в которые он попадал, освоение и его уровеньnext_tasksubmit_tasksubmit_answer8 КБ / 14 КБ
Недавние ответыПоследние 20 ответов и пропусков — и никогда не меньше 5 ответов, которые читает правилоnext_tasksubmit_answer4 КБ / 5 КБ
Текущая задача, запечатаннаяОтвет, ловушка за каждым неверным вариантом, решение и решатель — одна непрозрачная строкаnext_tasksubmit_tasksubmit_answer4 КБ / 8 КБ
Текущая задача, открытаяЧто показывает карточка: текст, рисунок, пять вариантов, подсказку — и, после ответа, сам ответnext_tasksubmit_tasksubmit_answer2 КБ / 4 КБ
Открытый запросПока задача пишется: её id, ТЗ, использованные попытки и кто выбрал темуnext_taskprepare_tasksubmit_task1,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 дней

Сервер без состояния не может пометить код использованным, поэтому окно для кода сделано маленьким. Прежний ключ после смены ещё принимается, поэтому ни токен, ни запечатанный ответ при смене ключа не пропадают.