Teach · техническая документацияTeach · technical documentation

Как устроен мобильный репетитор TeachHow the Teach mobile tutor works

Клиент на React Native, сервер-оркестратор на .NET со шлюзом моделей (OpenRouter или напрямую Anthropic), контракт между ними и порядок работы: от мастера предмета до предзагрузки следующего урока. Страница отдаётся самим сервером и обновляется вместе с ним.A React Native client, a .NET orchestrator server with a model gateway (OpenRouter or Anthropic directly), the contract between them and the order of operations: from the subject wizard to the prefetch of the next lesson. This page is served by the server itself and is updated together with it.

СерверServer
teach-tutor-api.fly.dev · .NET 10 · SQLite · Fly.io (ams)
КлиентClient
React Native · Expo SDK 57 · iOS через TestFlightReact Native · Expo SDK 57 · iOS via TestFlight
МоделиModels
OpenRouter; на проде gemini-3.1-flash-lite для большинства ролей, claude-sonnet-5 для источников, claude-haiku-4.5 для оценки; роль переключается настройкойOpenRouter; in production gemini-3.1-flash-lite for most roles, claude-sonnet-5 for sources, claude-haiku-4.5 for grading; each role is switched by a setting
Справка APIAPI reference
Scalar · openapi/v1.json
ТребованияRequirements
Функциональные требованияFunctional requirements · Тестовая документация QA (docx)QA test documentation (docx)
КодCode
github.com/okirasov/teach
1

Обзор и архитектураOverview and architecture

Teach учит любой теме по одному методу: объяснение → практика без подсказок → разбор → повторы. Ошибки становятся записями, из записей собирается следующий урок и карточки повторов. Система состоит из двух частей.Teach teaches any topic with one method: explanation → practice without hints → recap → reviews. Mistakes become learning records; the records are used to build the next lesson and the review cards. The system has two parts.

КЛИЕНТCLIENTПриложениеAppэкраны, сессия урока, повторы (FSRS), справочники, голос и озвучка; локальная SQLite на аккаунт; работает офлайн для повторов и справочниковscreens, lesson session, reviews (FSRS), references, voice input and speech output; a local SQLite database per account; works offline for reviews and references
СЕРВЕРSERVERОркестраторOrchestratorмастер предмета, генерация уроков структурированным выводом, валидация, этапы плана, предзагрузка, глоссарий, оценка свободных ответовsubject wizard, lesson generation with structured output, validation, plan stages, prefetch, glossary, grading of free-form answers
МОДЕЛЬMODELШлюз моделейModel gatewayOpenRouter: Claude через Messages, другие модели через Chat Completions; модель на каждую роль, журнал генерацийOpenRouter: Claude via Messages, other models via Chat Completions; a model per role, generation journal

Клиент никогда не держит ключей модели и не планирует обучение: он ходит только вThe client never holds model keys and never plans the learning: it only talks to ContentService, реализация которого включается адресом сервера. Всё, что у модели занимает дольше нескольких секунд, сервер делает фоновой задачей, а клиент опрашивает статус, потому что iOS обрывает HTTP-запрос через 60 секунд., whose implementation is selected by the server address. Anything that takes the model longer than a few seconds runs on the server as a background job, and the client polls its status, because iOS drops an HTTP request after 60 seconds.

КомпонентыComponents

flowchart TB
  subgraph Phone ["iPhone"]
    direction LR
    WG["Widget"] -. App Group .- UI["Expo app<br/>screens, session, FSRS"]
    UI --- LDB[("SQLite per account")]
    UI --- VO["STT and TTS<br/>on device"]
  end
  subgraph Fly ["Fly.io · teach-tutor-api"]
    direction LR
    API["ASP.NET<br/>minimal API"] --> Q["LessonWorker<br/>queue"]
    API --> DB[("SQLite<br/>on volume")]
    Q --> DB
    API --> GW["Model<br/>gateway"]
    Q --> GW
  end
  subgraph OR ["OpenRouter"]
    direction LR
    M1["Gemini 3.1<br/>Flash Lite"]
    M2["Claude<br/>Sonnet 5"]
    M3["Claude<br/>Haiku 4.5"]
  end
  UI -- "HTTPS, Bearer, SSE" --> API
  GW -- "Messages or<br/>Chat Completions" --> OR
  DB -. Litestream .-> T[("Tigris")]
  UI -. Sign in with Apple .-> AP["Apple"]
  API -. JWKS .-> AP
Телефон хранит повторы и справочники у себя и работает офлайн; всё, что требует модели, делает сервер. Пунктир — вспомогательные связи.The phone keeps reviews and references locally and works offline; everything that needs a model runs on the server. Dashed lines are auxiliary links.

Поток одного предметаFlow of one subject

01МастерWizardтема → фокус → миссия → источники (web search) → план на этапыtopic → narrowing → mission → sources (web search) → plan in stages
02ДиагностикаInitial check-inурок 1: калибровка без правильного ответа, свободный рассказ, одна проверка без подготовки («СТАРТ»)lesson 1: calibration without a correct answer, a free-form account, one check without preparation ("BASELINE")
03Урок NLesson Nобъяснение с источником и 2–3 практики, собранные по записям предыдущих разборов и этапу планаan explanation with a source and 2–3 practice steps, built from the learning records of previous recaps and the plan stage
04РазборRecapзаписи уходят на сервер; решается этап; заготовка N+1 отдаётся сразу или собирается зановоrecords go to the server; the plan stage is decided; the prefetched lesson N+1 is served immediately or rebuilt
Метки вродеTags like FR-24 ведут к требованию вlink to the requirement in the функциональных требованияхfunctional requirements; там же у каждого требования перечислены тест-кейсы QA. Эта страница описывает, как требование устроено внутри.; each requirement there also lists its QA test cases. This page describes how a requirement is implemented internally.
2

КлиентClient

React Native + Expo SDK 57, Expo Router, TypeScript, zustand, jest-expo. Вид — нативный iOS по спеку редизайна (docs/superpowers/specs/2026-10-06-native-ios-redesign-design.md): системная палитра и шрифт SF Pro, тема «Системная / Светлая / Тёмная», у каждого предмета свой системный цвет. Оболочка — системный таб-бар expo-router (NativeTabs) с большими заголовками, листы и зум-переход; движение на reanimated 4 и gesture-handler 3, стекло — expo-glass-effect. Golos Text остался только в словотипе.React Native + Expo SDK 57, Expo Router, TypeScript, zustand, jest-expo. The look is native iOS per the redesign spec (docs/superpowers/specs/2026-10-06-native-ios-redesign-design.md): the system palette and the SF Pro font, a “System / Light / Dark” theme, and a system color per subject. The shell is the expo-router system tab bar (NativeTabs) with large titles, sheets and a zoom transition; motion runs on reanimated 4 and gesture-handler 3, glass on expo-glass-effect. Golos Text remains only in the wordmark.

СтруктураStructure

ПапкаFolderЧто тамContents
app/экраны Expo Router: вход, интро, вкладки (Сегодня, Повторы, Справочники, Предметы — у каждой свой Stack и шапка в одну строку: название крупным шрифтом и аватар), сессия урока, разбор; мастер предмета, настройки предмета и профиль открываются листамиExpo Router screens: sign-in, intro, tabs (Today, Reviews, References, Subjects — each with its own Stack and a one-row header: the name in large type and the avatar), lesson session, recap; the subject wizard, subject settings and profile open as sheets
src/domain/типы урока и шагов, FSRS (ts-fsrs), языки, демо-предметы (lesson and step types, FSRS (ts-fsrs), languages, demo subjects (demo/en, demo/speak)
src/content/ContentService: HTTP-реализация с опросом задач и локальная заглушка для разработки без сервера: an HTTP implementation with job polling and a local stub for development without a server
src/features/движок сессии (чистые функции без React), план и этапы, повторы, справочники, подготовка предмета, озвучка, синхронизация с сервером и восстановление на новом телефоне (sync/)session engine (pure functions without React), plan and stages, reviews, references, subject preparation, speech output, sync with the server and restore on a new phone (sync/)
src/store/zustand-сторы: auth, progress, session, reviews, refs, settings; персистентные срезы пишутся в kv локальной БДzustand stores: auth, progress, session, reviews, refs, settings; persistent slices are written to the kv table of the local database
src/db/expo-sqlite на аккаунт (expo-sqlite per account (teach-<accountId>.db), версионированные миграции, репозитории карточек, справочников и kv, засев демо), versioned migrations, repositories for cards, references and kv, demo seeding
src/voice/распознавание речи (expo-speech-recognition, симуляция для web) и озвучка (expo-speech), деление текста по письменностиspeech recognition (expo-speech-recognition, simulated on web) and speech output (expo-speech), splitting text by script

Что важно знатьKey points

  • ВкладкиTabs: у каждой своё главное. «Сегодня» — плитка «Следующий урок» в цвете предмета, из неё урок раскрывается зумом (iOS 18+), блок повторов и список предметов. «Повторы» — чипы предметов, колода (жест в UI-потоке; чистые функции project, rubberband, decideFling в deck.ts; смахнутая карточка уходит в конец, без оценки), «Начать · N карточек» с учётом лимита, прогноз стопкой цветов предметов и бейдж на вкладке. «Справочники» — системный поиск в шапке, плитки «Все термины» и «Слабые места», группы по предметам; у строк с открытой ошибкой значок «!», тап открывает лист «Где ошиблись» (ref-miss). «Предметы» — кольца этапов и миссия с правкой на месте. Вкладки не размонтируются, поэтому дату «сегодня» держит useToday: после ночи в фоне бейдж, колода и числа пересчитываются на новый день.: each has its own focus. Today — a “Next lesson” tile in the subject color that opens the lesson with a zoom (iOS 18+), a reviews block and a subject list. Reviews — subject chips, a card deck (the gesture runs on the UI thread; pure functions project, rubberband, decideFling in deck.ts; a flicked card goes to the back, no grading), “Start · N cards” within the daily cap, a forecast stacked by subject colors and a tab badge. References — a system search in the header, “All terms” and “Weak spots” tiles, groups by subject; rows with an open mistake show a “!” badge, and a tap opens the “Where it went wrong” sheet (ref-miss). Subjects — stage rings and a mission edited in place. Tabs stay mounted, so useToday holds the current date: after a night in the background the badge, the deck and the counts recount for the new day. FR-42 FR-50
  • Режимы фокусаFocus modes: урок, итог и разговор берут цвет предмета из контекста SubjectTint (сегменты, капсула subject, подложка примера). Разбор ответа — лист снизу (reanimated SlideInDown/FadeIn при «Уменьшить движение»), итог — StageRing 120 pt с хаптиком при замыкании этапа. В разговоре кольцо микрофона следует за громкостью: volumechange из expo-speech-recognition → voiceLevel (0…1) → useTalk().level. В тёмной теме плитка героя приглушена к фону и без свечения.: the lesson, recap and talk take the subject’s color from the SubjectTint context (segments, the subject capsule, the example backdrop). The answer recap is a bottom sheet (reanimated SlideInDown, FadeIn under Reduce Motion), the recap has a 120 pt StageRing with a haptic when a stage closes. In the talk, the mic ring follows loudness: volumechange from expo-speech-recognition → voiceLevel (0…1) → useTalk().level. In dark mode the hero tile is muted towards the background and has no glow.
  • НастройкиSettings: Профиль и настройки предмета — формы SwiftUI через @expo/ui (Form в Host на весь лист, тема из приложения). Примитивы — src/ui/settings: SettingsFormSwift на iOS и SettingsFormRN для веб-превью, общий контракт в types.ts. Удаление и выход — confirmDestructive (системный Alert). Выключатель напоминаний remindersOn сохраняется в базе аккаунта и в снимке синхронизации. Палитра предметов приглушённая.: Profile and subject settings are SwiftUI forms via @expo/ui (Form in a full-sheet Host, theme from the app). The primitives live in src/ui/settings: SettingsFormSwift on iOS and SettingsFormRN for the web preview, with a shared contract in types.ts. Deleting and signing out use confirmDestructive (a system Alert). The remindersOn switch is saved in the account database and in the sync snapshot. The subject palette is muted.
  • Сессия урокаLesson session — чистый движок: активный шаг и показанный, ответы пройденных шагов заморожены, свайпы назад и вперёд до активного. Крестик сохраняет снимок сессии в предмете, следующее открытие того же урока продолжает с места остановки.— a pure engine: an active step and a displayed step, answers of completed steps are frozen, swipes go back and forward up to the active step. The close button saves a session snapshot in the subject; the next time the same lesson is opened, it resumes where it stopped. FR-20 FR-28 FR-29
  • БаддиBuddy — док над главной кнопкой сессии: четыре точки знака и слово состояния. Чистая стейт-машина— a dock above the main session button: a four-dot mark and a state word. A pure state machine buddyStateOf над флагами, которые уже есть (запись, озвучка, оценка на сервере, тон фидбека): слушает > читает пример > думает > читайте > реакция > ваш ход. Реакции «Верно / Частично / Не совсем» играют один раз на шаг; шаги без оценки (калибровка, промах на диагностике) дают «Принято». Анимация на RN Animated, при «Уменьшении движения» только конечные позы; хаптика через expo-haptics с флагом.over flags that already exist (recording, speech output, server grading, feedback tone): "Listening" > "Reading the example" > "Thinking" > "Read on" > reaction > "Your turn". The reactions "Correct / Partly / Not quite" play once per step; steps without grading (calibration, a miss in the Initial check-in) show "Noted". Animation uses RN Animated; with Reduce Motion enabled only the end poses are shown; haptics via expo-haptics behind a flag. FR-38
  • Подсказка о новой сборкеNew build nudge: при запуске на iOS клиент один раз спрашивает: on launch on iOS the client calls GET /app/latest и, если номер выше своегоonce and, if the number is higher than its own ios.buildNumber, показывает на «Сегодня» строку «Есть сборка N» с кнопкой в TestFlight и крестиком до следующего запуска. Сбои сети молчат, локальная заглушка обновлений не знает., shows a "Build N is out" row on Today with a button to TestFlight and a close button that hides it until the next launch. Network failures are silent; the local stub knows nothing about updates. FR-08
  • Проверка ответов на устройствеOn-device answer checking: выбор и порядок точно, ввод по группам синонимов, свободный ответ по ключевым словам критериев. Свободный ответ дополнительно уходит на: choice and order are checked exactly, text input by synonym groups, free-form answers by the keywords of the criteria. A free-form answer is also sent to POST /grade/free с потолком 4 с; вердикт сервера заменяет проверку по ключам, а таймаут, ошибка или офлайн оставляют её.with a 4 s cap; the server's verdict replaces the keyword check, while a timeout, an error or offline mode keeps it. FR-23 FR-24
  • ПовторыReviews: одна учебная точка — одна карточка; сроками владеет FSRS без краткосрочных шагов (ошибка → 1 день, успех → 3 дня). Карточка знает свой предмет, поэтому язык распознавания и озвучки в повторах берётся у него.: one learning point is one card; due dates are owned by FSRS without short-term steps (miss → 1 day, success → 3 days). A card knows its subject, so the recognition and speech output language in reviews comes from the subject. FR-40 FR-41 FR-62
  • Прогноз нагрузки и режим запоминанияLoad forecast and recall mode: прогноз на 14 дней считается на устройстве прогоном FSRS с ответом «помню» и ожидаемой долей ошибок 1 − R плюс приток новых карточек предмета; режим «Поддерживать / Обычно / Интенсивно» — цель FSRS 0,85 / 0,90 / 0,95, смена пересчитывает сроки карточек предмета от последнего ответа.: the 14-day forecast is computed on the device by running FSRS with a "remembered" answer and an expected miss rate of 1 − R, plus the inflow of new cards for the subject; the "Maintain / Normal / Intensive" mode sets the FSRS target to 0.85 / 0.90 / 0.95, and changing it recalculates the due dates of the subject's cards from the last answer. FR-45 FR-46
  • Виджет iOSiOS widget: отдельный таргет: a separate target ExpoWidgetsTarget, данные через App Group, data via the App Group group.app.teach.mobile. Приложение пишет расписание на сейчас и 7 ближайших полуночей, поэтому число карточек меняется без запуска; тапы открывают. The app writes a timeline for now and the next 7 midnights, so the card count changes without launching the app; taps open teach://. Подробности отладки —. Debugging details are in mobile/README.md. FR-47
  • НапоминанияReminders — локальные уведомления без сервера, расписание перестраивается при смене настроек и снимается при выходе.— local notifications without a server; the schedule is rebuilt when settings change and removed on sign-out. FR-07
  • Синхронизация и восстановлениеSync and restore — модуль src/features/sync: в фоне отправляет на сервер изменённые карточки, новые ответы, состояние аккаунта и удаления предметов; что уже отправлено, лежит в kv под ключом sync. На новом телефоне DbGate до интро показывает RestoreScreen и собирает базу из GET /sync/restore. Подробности — в разделе «Переезд на новый телефон».— the src/features/sync module: in the background it sends changed cards, new answers, the account state and subject deletions to the server; what has already been sent is kept in kv under the sync key. On a new phone DbGate shows RestoreScreen before the intro and builds the database from GET /sync/restore. Details are in the Moving to a new phone section. FR-44 FR-74 SR-21
  • Разговор с баддиTalk with the buddy: свободный голосовой разговор по теме предмета, вход — «Поговорить» в плитке «Следующий урок» или кнопка со знаком бадди в строке предмета на «Сегодня». Платформенный STT на телефоне →: a free voice conversation on the subject's topic; the entry point is "Talk" on the "Next lesson" tile or the buddy button in a subject row on Today. Platform STT on the phone → POST /talks/{id}/turns по SSE → первое предложение уходит вover SSE → the first sentence goes to expo-speech до конца стрима. Тот же док и знак, новое слово «Говорю»; тап по микрофону во время «Говорю» перебивает озвучку и сразу слушает — на клиенте это счётчик поколений хода, который гасит устаревший стрим. Бадди говорит на «ты», интерфейс на «вы».before the stream ends. The same dock and mark, with a new word "Speaking"; tapping the microphone during "Speaking" interrupts the speech output and starts listening immediately — on the client this is a turn generation counter that discards the stale stream. In the Russian UI the buddy addresses the learner informally («ты») while the interface uses the formal «вы». FR-66
  • Голос никогда не блокируетVoice never blocks: без разрешения или без голоса для языка кнопки просто не рисуются, текстовый ввод доступен всегда.: without permission or without a voice for the language the buttons are simply not drawn; text input is always available. FR-63 FR-65
  • Несколько предметовMultiple subjects: у каждого свой урок, подготовка, прерванная сессия, повторы, справочники и настройки; id выдаёт монотонный счётчик; цвет предмета хранится в его настройках (демо — синий и оранжевый, новый получает первый свободный). Плюс два демо, сгенерированные этим же сервером и зашитые статично: цепочка уроков 1–6 с глоссарием и сменой этапа, после урока 6 — мастер своего предмета.: each has its own lesson, preparation, interrupted session, reviews, references and settings; ids come from a monotonic counter; the subject color lives in its settings (the demos are blue and orange, a new subject gets the first free color). Plus two demos generated by this same server and bundled statically: a chain of lessons 1–6 with a glossary and a stage change; after lesson 6 comes the wizard for the user's own subject. FR-16 FR-34
  • Живой планLiving plan: модуль src/features/plan. Лист «План» (app/plan/[id], кнопка в блоке плана PlanBlock на вкладке «Предметы») показывает этапы, текущий и пройденные, «почему» последнего пересмотра и подпись о закрытии этапа по stageCloseAt; при открытии план подтягивается с сервера. Отметка «· план обновлён» в строке предмета держится, пока лист не открыт (planSeen). Миссия уходит на сервер через очередь outbox с меткой времени правки: повтор при сети, 400 и 404 снимают правку; через 20 с после отправки план обновляется. Кольцо итога урока честное: этап «закрыт», только если доля верных ≥ stageCloseAt. Режим правки («Изменить»): на iOS — SwiftUI List из @expo/ui в режиме правки (List.ForEach с onDelete / onMove, PlanEditorSwift), на web-превью — RN-список с кнопками ↑ / ↓ / ✕ (PlanEditorRN); черновик — отдельный стор usePlanDraft, текст этапа правится в листе app/plan-stage/[slot]. «Готово» сразу меняет план в сторе (editPlan закрепляет этапы так же, как сервер, по LCS) и кладёт в ту же очередь outbox элемент { kind: "plan", baseVersion, current?, ahead, at }; неотправленные правки плана сливаются в одну (последнее состояние, baseVersion первой). Перед «Дальше», возвратом, разбором и обновлением плана очередь отправляется первой. 409 stale кладёт план из ответа и один раз показывает в листе «План успел обновиться…» (planConflict). «Этот этап я уже знаю — дальше» (skipStage) — только в сети, на диагностике и при подготовке недоступно (skipBlock); после 202 сохранённая сессия урока стирается, предмет переходит в подготовку без записей и ждёт урок N+1. «Вернуть прежний план» показывается по planRevertable; «История изменений» — лист app/plan-history/[id], только в сети.: the src/features/plan module. The “Plan” sheet (app/plan/[id], a button in the PlanBlock on the “Subjects” tab) shows the stages, the current and passed ones, the “why” of the last revision and a stage-closing caption from stageCloseAt; opening it refreshes the plan from the server. The “· plan updated” mark on the subject row stays until the sheet is opened (planSeen). The mission goes to the server through the outbox queue with the edit timestamp: retried when online, dropped on 400 and 404; the plan refreshes 20 s after sending. The lesson summary ring is honest: the stage shows “closed” only if the correct rate is ≥ stageCloseAt. Edit mode (“Edit”): on iOS a SwiftUI List from @expo/ui in edit mode (List.ForEach with onDelete / onMove, PlanEditorSwift), on the web preview an RN list with ↑ / ↓ / ✕ buttons (PlanEditorRN); the draft is a separate usePlanDraft store, a stage’s text is edited in the app/plan-stage/[slot] sheet. “Done” changes the plan in the store at once (editPlan pins stages the same way the server does, by LCS) and puts { kind: "plan", baseVersion, current?, ahead, at } into the same outbox queue; unsent plan edits merge into one (the latest state with the first baseVersion). Before “Next”, restore, a recap and a plan refresh the queue is sent first. A 409 stale stores the plan from the response and shows “The plan changed in the meantime…” once on the sheet (planConflict). “I already know this stage — next” (skipStage) needs a connection and is disabled on the Initial check-in and while preparing (skipBlock); after the 202 the saved lesson session is cleared, the subject goes into preparation without records and waits for lesson N+1. “Restore the previous plan” shows on planRevertable; “Change history” is the app/plan-history/[id] sheet, online only. FR-33

Состояния на клиентеClient states

Метки на диаграммах — идентификаторы и события из кода, чтобы по ним можно было искать.

Diagram labels are code identifiers and events, so they can be searched for.

stateDiagram-v2
    [*] --> active : start or restored snapshot
    active --> active : primary on explain, next step
    active --> answered : Answer, canProceed
    answered --> active : Next or swipe forward
    answered --> recap : last step, to recap
    active --> viewingPast : swipe back
    answered --> viewingPast : swipe back
    viewingPast --> viewingPast : swipe within past steps
    viewingPast --> active : To current step or swipe
    active --> saved : close, saveSession
    answered --> saved : close, saveSession
    saved --> active : reopen same lesson, restoreSession
    recap --> [*] : Done, clearSession
Сессия урока (src/features/session/engine.ts): активный шаг, просмотр пройденных, снимок при крестике. FR-28 FR-29Lesson session (src/features/session/engine.ts): active step, viewing past steps, snapshot on close. FR-28 FR-29
flowchart TD
  A{"rec?"} -- yes --> L["Listening"]
  A -- no --> B{"speaker<br/>reading?"}
  B -- yes --> R["Reading the example"]
  B -- no --> C{"grading<br/>on server?"}
  C -- yes --> T["Thinking"]
  C -- no --> D{"past step<br/>or explain?"}
  D -- yes --> RO["Read on"]
  D -- no --> E{"answered?"}
  E -- no --> Y["Your turn"]
  E -- yes --> F{"neutral step?"}
  F -- yes --> N["Noted"]
  F -- no --> G["Correct · Partly · Not quite"]
Слово бадди (buddyStateOf) — чистая функция приоритета: побеждает первое сработавшее условие. FR-38The buddy word (buddyStateOf) is a pure priority function: the first matching condition wins. FR-38
stateDiagram-v2
    [*] --> waiting
    waiting --> thinking : intro or send
    waiting --> listening : mic tap
    listening --> thinking : STT end with text
    listening --> waiting : empty text or cancel
    thinking --> speaking : stream open
    speaking --> waiting : queue empty, stream closed
    speaking --> listening : mic tap, barge-in
    thinking --> waiting : error
    waiting --> ended : close
    note right of speaking
        first sentence goes to TTS
        while the stream continues
    end note
    note left of ended
        close from any state
        ends the talk
    end note
Ход разговора (talkMachine.ts + useTalk). Перебивание увеличивает счётчик поколений: все продолжения старого хода молча выходят. FR-66Talk turn (talkMachine.ts + useTalk). A barge-in bumps the generation counter, so every continuation of the old turn exits silently. FR-66
stateDiagram-v2
    [*] --> draft : wizard Done
    draft --> polling : POST /subjects ok
    draft --> failed : POST error
    polling --> ready : ready, number reached
    polling --> outage : network error or 5xx
    outage --> polling : next GET ok
    outage --> failed : over 3 min
    polling --> failed : 4xx, failed or 10 min
    ready --> polling : recap sent
    failed --> polling : Retry
    note right of polling
        GET lesson every 3 s.
        After an app restart
        polling resumes.
    end note
Подготовка урока на клиенте: опрос раз в 3 с, сеть и 5xx терпятся до 3 минут, после перезапуска опрос возобновляется. FR-15 FR-36Lesson preparation on the client: polling every 3 s, network and 5xx tolerated for 3 minutes, polling resumes after a restart. FR-15 FR-36

Карточки повторов в FSRS без краткосрочных шагов: новая карточка сразу получает состояние Review, «помню» — Good, ошибка — Again; режим запоминания меняет только целевую вероятность 0,85 / 0,90 / 0,95 и пересчитывает сроки без записи в журнал ответов.

Review cards use FSRS without short-term steps: a new card goes straight to Review, a correct answer is Good, a miss is Again; the recall mode only changes the target retention 0.85 / 0.90 / 0.95 and reschedules without writing to the answer log.

Локальная базаLocal database

erDiagram
    review_cards {
        TEXT id PK
        TEXT subject_id "IX"
        TEXT subject_name
        TEXT title
        TEXT note
        TEXT source
        TEXT ref_json "null, step and question snapshot"
        INTEGER created_at
        INTEGER updated_at "sync version, epoch ms"
        INTEGER miss "open mistake: last counted answer wrong (FR-53)"
        INTEGER due "IX, epoch ms"
        REAL stability
        REAL difficulty
        INTEGER elapsed_days
        INTEGER scheduled_days
        INTEGER reps
        INTEGER lapses
        INTEGER learning_steps
        INTEGER state "ts-fsrs State"
        INTEGER last_review "null"
    }
    review_log {
        INTEGER id PK
        TEXT card_id
        INTEGER rating
        INTEGER state
        INTEGER due
        REAL stability
        REAL difficulty
        INTEGER scheduled_days
        INTEGER reviewed_at
    }
    refs {
        TEXT id PK
        TEXT subject_id
        TEXT subject_name
        TEXT grp
        TEXT title
        INTEGER updated_after
        INTEGER sort
    }
    ref_rows {
        INTEGER id PK
        TEXT ref_id "IX"
        TEXT sec "null"
        TEXT k
        TEXT v
        INTEGER weak "unused since FR-53, written 0"
        TEXT links_json "null, [{lesson, step}] (FR-53)"
        INTEGER sort
    }
    kv {
        TEXT key PK "settings, progress, demo.seeded.v3, sync"
        TEXT value "JSON snapshot"
    }
    review_cards ||..o{ review_log : "card_id"
    refs ||..o{ ref_rows : "ref_id"
Файл teach-<accountId>.db на аккаунт, миграции по PRAGMA user_version. Пометка ошибки у строки справочника не хранится: она вычисляется из review_cards.miss и связей строки (features/refs/marks.ts, useMarkedRefs), миграция v5. Настройки и прогресс лежат JSON-снимками в kv, там же состояние отправки на сервер (sync); токен — в защищённом хранилище, не в SQLite.One teach-<accountId>.db file per account, migrations by PRAGMA user_version. A reference row’s mistake mark is not stored: it is computed from review_cards.miss and the row’s links (features/refs/marks.ts, useMarkedRefs), migration v5. Settings and progress are JSON snapshots in kv, and so is the state of sending to the server (sync); the token lives in secure storage, not in SQLite.
3

СерверServer

Минимальный API на .NET 10 с EF Core и SQLite на томе Fly. Один процесс: HTTP-обработчики, фоновая очередь уроков (A minimal API on .NET 10 with EF Core and SQLite on a Fly volume. One process: HTTP handlers, a background lesson queue (LessonWorker), задачи поиска источников, плавная остановка.), source search jobs, graceful shutdown.

Воркер: глоссарий и перезапускиWorker: glossary and restarts

Как собирается урок и какие модели участвуют — в разделе «Генерация контента и модели».

How a lesson is built and which models take part — see Content generation and models.

  • Глоссарий.Glossary. После каждого урока модель роли Glossary получает термины предмета и пронумерованные шаги урока и возвращает 3–6 терминов с шагами-вопросами, которые их проверяют; сервер оставляет связи только на шаги практики этого урока (links: [{ lesson, step }], не больше 10 терминов за извлечение) и сливает термины в справочник предмета без дублей — они приходят вместе с уроком.After each lesson the Glossary role model gets the subject’s terms and the lesson’s numbered steps and returns 3–6 terms with the question steps that check them; the server keeps links only to practice steps of that lesson (links: [{ lesson, step }], at most 10 terms per extraction) and merges the terms into the subject's reference without duplicates — they arrive together with the lesson. FR-51 FR-53
  • Живой план.Living plan. План предмета хранится версиями в PlanVersions: у этапа есть id («s1», «s2»…, счётчик не переиспользуется) и автор текста by. Разбор решает судьбу этапа (StagePolicy.Verdict): два последних урока уверенно (≥ 75 % и ≥ 60 %) — этап закрыт, шесть уроков — закрыт по потолку. На рубеже воркер сначала зовёт роль Replan (этапы впереди, миссия, записи, сводка уроков), валидатор оставляет 1–4 этапа или все закреплённые, сохраняет закреплённые дословно и пройденные неизменными, затем переход и урок по новой версии. Смена миссии (PUT /subjects/{id}/mission) ставит рубеж MissionChanged: пересмотр этапов после текущего без перехода. Сбой Replan версии не создаёт: переход идёт по старому плану, рубеж по миссии остаётся и выполнится перед следующим уроком. Пока рубеж ждёт, заготовка не собирается. Статус урока отдаёт план с id, номер версии, последнее изменение моделью (planChange с одной фразой «почему») и stageCloseAt — порог, при котором готовый урок закроет этап. Старые предметы получают версию 1 при старте сервера. Человек правит план через PUT /subjects/{id}/plan (PlanBook.ApplyUserEdit: новые, переписанные и переставленные вне наибольшей общей подпоследовательности этапы получают by = user, версия UserEdit), закрывает этап сам (POST …/plan/skip: урок помечается Skipped, рубеж StageSkipped, задача Prepare) и отменяет последнее изменение моделью (POST …/plan/revert, PlanBook.Revert: зона версии модели, но не раньше текущего этапа, — из предыдущей версии, закреплённая; версия Revert с RestoredFrom). Все три сверяют baseVersion и на расхождение отвечают 409 stale с текущим планом. Правка или возврат текста текущего этапа сбрасывает заготовку и дайджест; заготовка, собранная, пока версия плана сменилась, отбрасывается. Пропущенные уроки не входят в сводку генераций.The subject plan is stored as versions in PlanVersions: each stage has an id (“s1”, “s2”…, the counter is never reused) and a text author by. The recap decides the stage’s fate (StagePolicy.Verdict): the last two lessons confident (≥ 75 % and ≥ 60 %) close the stage, six lessons close it by the cap. At a boundary the worker first calls the Replan role (stages ahead, mission, records, lesson summary); the validator keeps 1–4 stages or all pinned ones, keeps pinned stages verbatim and passed stages unchanged; then the transition and the lesson on the new version. A mission change (PUT /subjects/{id}/mission) sets a MissionChanged boundary: revision of the stages after the current one, no transition. A Replan failure creates no version: the transition uses the old plan, and a mission boundary stays and runs before the next lesson. While a boundary waits, no prefetch is built. The lesson status returns the plan with ids, the version number, the last model change (planChange with a one-line “why”) and stageCloseAt, the rate at which the ready lesson closes the stage. Older subjects get version 1 at server start. The person edits the plan with PUT /subjects/{id}/plan (PlanBook.ApplyUserEdit: new, rewritten and moved stages — outside the longest common subsequence — get by = user, a UserEdit version), closes a stage themselves (POST …/plan/skip: the lesson is marked Skipped, a StageSkipped boundary, a Prepare job) and undoes the model’s last change (POST …/plan/revert, PlanBook.Revert: the model version’s zone, but not before the current stage, comes back from the previous version, pinned; a Revert version with RestoredFrom). All three check baseVersion and answer a mismatch with 409 stale and the current plan. Editing or restoring the current stage’s text resets the prefetch and the digest; a prefetch built while the plan version changed is discarded. Skipped lessons are left out of the generation summary. FR-33 SR-22 SR-23
  • Перезапуски.Restarts. Повторный разбор того же урока идемпотентен; очередь в памяти, при старте предметы «в подготовке» ставятся заново; по SIGINT воркер дорабатывает текущий урок при живом сервере (A repeated recap of the same lesson is idempotent; the queue is in memory, and on startup subjects that are "preparing" are queued again; on SIGINT the worker finishes the current lesson while the server stays alive (DrainingLifetime), Fly ждёт до 160 секунд.), and Fly waits up to 160 seconds. SR-11 SR-12 SR-13

Голосовой разговор с баддиVoice talk with the buddy

  • ЭндпоинтыEndpoints POST /subjects/{id}/talks → POST /talks/{id}/turns со стримингом по SSE: клиент шлёт распознанный текст, сервер отдаёт реплику кусками, клиент запускает озвучку с первого предложения;with SSE streaming: the client sends the recognized text, the server returns the reply in chunks, and the client starts speech output from the first sentence; POST /talks/{id}/end завершает разговор и извлекает записи (FR-68).ends the talk and extracts the records (FR-68). SR-16 FR-68
  • Промпт из трёх слоёвA three-layer prompt сwith cache_control: персона и правила бадди (общие для всех), дайджест предмета (общий для предмета), хвост диалога (меняется). Ответ устный, 1–3 предложения,: the buddy's persona and rules (shared by everyone), the subject digest (shared per subject), the dialogue tail (changes). The reply is spoken, 1–3 sentences, max_tokens 400 с учётом thinking, модель роли Talk (в коде по умолчанию sonnet, на проде gemini-3.1-flash-lite) с низким effort.400 including thinking, the Talk role model (sonnet by default in code, gemini-3.1-flash-lite in production) with low effort. FR-67
  • Дайджест предметаSubject digest на 1–2 тыс. токенов: тема, фокус, миссия, этап, глоссарий, свёртка записей. Собирается моделью роли Digest задачей воркера после каждого разбора и хранится в строке предмета; на каждую реплику не пересобирается. Уроки целиком в контекст не идут.of 1–2 thousand tokens: topic, focus, mission, stage, glossary, a summary of the records. It is built by the Digest role model as a worker job after each recap and stored in the subject row; it is not rebuilt for every turn. Whole lessons are not included in the context.
  • ИсторияHistory хранится на сервере: последние 10 реплик, старое сворачивается в две фразы. Кэш живёт 5 минут, живой разговор сам его греет.is stored on the server: the last 10 turns; older ones are folded into two sentences. The cache lives 5 minutes, and a live conversation keeps it warm by itself.
  • ЭкономикаCost на sonnet: ~3 тыс. кэшированных токенов на чтение, 200–400 свежих, ~150 на ответ, около $0.003 за реплику и ~$0.05 за разговор на 20 реплик; на gemini-3.1-flash-lite заметно дешевле. Фактическую цену показывает журнал генераций.on sonnet: ~3 thousand cached tokens read, 200–400 fresh, ~150 per reply, about $0.003 per turn and ~$0.05 per 20-turn talk; on gemini-3.1-flash-lite noticeably cheaper. The generation journal shows the actual price.
  • ЗадержкаLatency от конца фразы до начала речи: STT на устройстве мгновенный, стриминг даёт первое предложение за 1–1.5 с; без стриминга 2–3 с, для голоса это уже заметно.from the end of the phrase to the start of speech: on-device STT is instant, streaming delivers the first sentence in 1–1.5 s; without streaming 2–3 s, which is already noticeable for voice.
  • Записи из разговораTalk records извлекает роль TalkRecords по дайджесту и полной расшифровке (она хранится отдельно от свёрнутой истории): до пяти записей «уверенно» или «путался», у «путался» — один шаг для повтора (выбор или ввод) и до трёх терминов глоссария предмета, о которых речь (написанием глоссария, FR-53), шаг проверен тем же валидатором, что шаги урока; невалидный шаг заменяется пустым. Меньше двух реплик пользователя, демо-разговор или удалённый предмет — статус empty без вызова модели. Вызов синхронный, ограничен 90 секундами; ошибка или таймаут дают failed (терминальный: повторный /end модель не зовёт). Статус извлечения переводится в running условным обновлением, поэтому параллельный вызов получает 409, а running старше двух минут считается брошенным и запускается заново. Записи сохраняются с Origin = talk и LessonNumber = 0, поэтому не влияют на идемпотентность разбора, переход этапа и качество в админке; в промпт следующего урока попадают строкой [ошибка · разговор]. После каждого успешного извлечения (done) дайджест помечается на пересборку; заготовка следующего урока сбрасывается, только если есть запись «путался». Повторный /end отдаёт сохранённый итог. Телефон досылает нерешённые разговоры при запуске и возврате в приложение.is extracted by the TalkRecords role from the digest and the full transcript (stored apart from the folded history): up to five “confident” or “confused” records, and for each “confused” one a single review step (choice or input) and up to three of the subject’s glossary terms it is about (in the glossary spelling, FR-53), the step checked by the same validator as lesson steps; an invalid step is replaced with none. Fewer than two user turns, a demo talk or a deleted subject give status empty with no model call. The call is synchronous and bounded by 90 seconds; an error or timeout gives failed (terminal: a repeated /end does not call the model). The extraction status moves to running by a conditional update, so a parallel call gets 409, and running older than two minutes counts as abandoned and is restarted. Records are saved with Origin = talk and LessonNumber = 0, so they do not affect recap idempotency, stage transitions or admin quality stats; they reach the next lesson prompt as a [ошибка · разговор] line. After every successful (done) extraction the digest is marked for rebuild; the next-lesson prefetch is cleared only if there is a “confused” record. A repeated /end returns the stored outcome. The phone resends unresolved talks on launch and on return to the app. FR-68

Схема базыDatabase schema

erDiagram
    Subjects {
        Guid Id PK
        string OwnerId "IX, apple:sub, tester:name or shared"
        string Topic
        string Title "null"
        string Focus
        string Mission
        string SourceIdsJson "JSON string array"
        string SourcesJson "null, JSON sources"
        int Status "0 Preparing 1 Ready 2 Failed"
        int PrepStage "0..2"
        string LessonJson "null, current lesson"
        int LessonNumber "0 none, 1 check-in"
        string PlanJson "null, JSON plan stages"
        int PlanStage "0-based"
        int PlanVersion "0 before backfill"
        int PlanStageSeq "s1, s2... never reused"
        string PendingReplan "null, boundary awaiting Replan"
        DateTimeOffset MissionEditedAt "null"
        string PreviousMission "null"
        int DurationMinutes "0 = server default"
        string PrefetchJson "null, lesson N+1"
        int PrefetchNumber
        int PrefetchStage
        bool PrefetchRunning
        long PrefetchGenerationRowId "null"
        bool LastFromPrefetch
        string PromptVersion "null"
        string LastError "null"
        string DigestText "null"
        int DigestLesson
        DateTimeOffset CreatedAt
        DateTime DeletedAt "null, deleted by the user (soft)"
        DateTimeOffset UpdatedAt
    }
    Lessons {
        long Id PK
        Guid SubjectId UK "UK with Number"
        int Number UK
        string Json "lesson"
        string PromptVersion
        string Model "null"
        long GenerationRowId "null"
        int StageIndex "null"
        int PlanVersion "null"
        bool Skipped "Next button"
        DateTimeOffset CreatedAt
    }
    PlanVersions {
        long Id PK
        Guid SubjectId UK "UK with Version"
        int Version UK
        string StagesJson "stages with id and by"
        string Author "Wizard Default Model User"
        string Reason "Created StageClosed StageCapped StageSkipped MissionChanged UserEdit Revert"
        string Why "null, model's line"
        int StageIndex
        int RestoredFrom "null"
        long GenerationRowId "null"
        DateTimeOffset CreatedAt
    }
    Records {
        long Id PK
        string SubjectId "IX, string: demo ids too"
        string Title
        string Note
        bool Ok
        int StepIndex
        int LessonNumber
        int PlanStage
        string Origin "lesson or talk"
        Guid TalkId "null"
        DateTimeOffset CreatedAt
    }
    References {
        Guid Id PK
        Guid SubjectId "IX"
        string Group "glossary key"
        string Title
        string RowsJson "JSON rows"
        int UpdatedAfter
        DateTimeOffset UpdatedAt
    }
    Talks {
        Guid Id PK
        Guid SubjectId "IX, empty for demo talks"
        string OwnerId
        string TurnsJson "last 10 turns"
        string OlderSummary "null"
        string TranscriptJson "full transcript"
        string ExtractStatus "none, running, done, empty, failed"
        DateTime ExtractStartedAt "null"
        string ExtractJson "null, /end result"
        string DigestText "null, demo only"
        bool Ended
        DateTimeOffset StartedAt
        DateTimeOffset UpdatedAt
    }
    Subjects ||..o{ Lessons : "SubjectId"
    Subjects |o..o{ Records : "SubjectId as string"
    Subjects ||..o{ References : "SubjectId"
    Subjects |o..o{ Talks : "SubjectId"
Предметы и обучение: текущий урок и заготовка лежат в строке предмета, история — в Lessons. Пунктир — логическая связь по id, внешних ключей здесь нет; Records.SubjectId — строка, чтобы принимать записи демо.Subjects and learning: the current lesson and the prefetch live in the subject row, history in Lessons. Dashed — a logical link by id, there are no foreign keys here; Records.SubjectId is a string so that demo records are accepted.
erDiagram
    ApiTokens {
        Guid Id PK
        string Hash UK "SHA-256"
        string OwnerId
        string Kind "user or tester"
        string Name "null"
        DateTimeOffset CreatedAt
        DateTimeOffset LastUsedAt "null"
        DateTimeOffset RevokedAt "null"
    }
    Generations {
        long Id PK
        DateTime CreatedAt "IX, UTC"
        string Role
        string ModelRequested
        string ModelAnswered
        string Transport "messages or chat"
        string Provider "null"
        string GenerationId "null"
        string PromptVersion
        Guid SubjectId "null"
        int LessonNumber "null"
        int Attempt
        bool Fallback
        string Profile "null"
        int LatencyMs
        int FirstTokenMs "null"
        int InputTokens
        int CachedTokens
        int CacheWriteTokens
        int OutputTokens
        int ReasoningTokens
        decimal CostUsd "null"
        string Stop
        bool Ok
        string Error "null"
        bool FormatOk "null"
    }
    ModelProfiles {
        string Name PK
        string ModelsJson "role to model id"
        string Note "null"
        DateTime CreatedAt
        DateTime UpdatedAt
    }
    ProfileAssignments {
        string OwnerId PK
        string ProfileName FK "IX"
        DateTime AssignedAt
    }
    AppSettings {
        string Key PK "ios.latestBuild"
        string Value
        DateTime UpdatedAt "UTC"
    }
    Subjects {
        Guid Id PK
        string OwnerId
    }
    ModelProfiles ||--o{ ProfileAssignments : "FK, Restrict"
    ApiTokens }o..o| ProfileAssignments : "OwnerId"
    Subjects |o..o{ Generations : "SubjectId"
    ApiTokens }o..o{ Subjects : "OwnerId"
Доступ и модели. Сплошная линия — единственный внешний ключ в базе: назначение → профиль, удаление назначенного профиля запрещено (API отвечает 409). Владелец — строка apple:<sub>, tester:<имя> или shared. AppSettings — отдельная таблица «ключ → значение», пока только номер последней сборки iOS.Access and models. The solid line is the only foreign key in the database: assignment → profile, deleting an assigned profile is restricted (the API answers 409). The owner is a string apple:<sub>, tester:<name> or shared. AppSettings is a standalone key → value table, for now only the latest iOS build number.
erDiagram
    SyncCards {
        string OwnerId PK "with CardId"
        string CardId PK
        string SubjectKey "IX with OwnerId, subject Guid in lower case or demo id"
        string SubjectName
        string Title
        string Note
        string Source
        string RefJson "null, step and question snapshot"
        string FsrsJson "ts-fsrs state"
        bool Miss "open mistake (FR-53), default false"
        long CreatedAt "phone, unix ms"
        long UpdatedAt "phone, unix ms, the newer wins"
    }
    SyncAnswers {
        string OwnerId PK "with AnswerId"
        string AnswerId PK "cardId:reviewedAt"
        string CardId
        int Rating
        int State
        long Due
        double Stability
        double Difficulty
        int ElapsedDays
        int LastElapsedDays
        int ScheduledDays
        int LearningSteps
        long ReviewedAt "IX with OwnerId"
    }
    SyncStates {
        string OwnerId PK
        string Json "account state"
        long UpdatedAt "phone, unix ms, the newer wins"
    }
    Subjects {
        Guid Id PK
        string OwnerId
        DateTime DeletedAt "null"
    }
    SyncCards |o..o{ SyncAnswers : "CardId"
    Subjects |o..o{ SyncCards : "SubjectKey as lower-case Guid"
Копия с телефона (FR-44): карточки, ответы и состояние по владельцу. Связи логические, внешних ключей нет: ответ может прийти раньше своей карточки. Карточки удалённого предмета остаются в таблице, но GET /sync/restore их не отдаёт.The copy from the phone (FR-44): cards, answers and state per owner. The links are logical, there are no foreign keys: an answer may arrive before its card. Cards of a deleted subject stay in the table, but GET /sync/restore does not return them.
ТаблицаTableСодержаниеContents
Subjectsвладелец, тема, имя, фокус, миссия, источники, план, текущий этап, статус подготовки, текущий урок, заготовка (PrefetchJson/Number/Stage), длительность, дайджест для разговора, DeletedAt — мягкое удаление пользователемowner, topic, name, focus, mission, sources, plan, current stage, preparation status, current lesson, prefetch (PrefetchJson/Number/Stage), duration, digest for the talk, DeletedAt — a soft delete by the user
Lessonsистория уроков предмета с версией промптовthe subject's lesson history with the prompt version
Recordsзаписи разборов: заголовок, заметка, результат, шаг, номер урока, этап, происхождение (урок или разговор), id разговораrecap learning records: title, note, result, step, lesson number, stage, origin (lesson or talk), talk id
Referencesглоссарий предметаthe subject's glossary
Talksразговоры с бадди: предмет или дайджест демо, история реплик и свёртка старого, полная расшифровка, статус и итог извлечения записейtalks with the buddy: subject or demo digest, turn history and a fold of older turns, the full transcript, the status and outcome of record extraction
ApiTokensтокены аккаунтов и тестировщиков: владелец, вид, SHA-256 хеш, последнее использование, отзывaccount and tester tokens: owner, kind, SHA-256 hash, last use, revocation
Generationsжурнал вызовов модели: роль, модель, транспорт, профиль, задержка, токены, цена, ошибка, формат; 90 днейmodel call journal: role, model, transport, profile, latency, tokens, price, error, format; 90 days
SyncCardsкопия карточек повторов с телефона: владелец и id карточки, SubjectKey, заголовок, заметка, снимок вопроса, состояние FSRS, метки CreatedAt и UpdatedAt (побеждает более новая)a copy of the phone's review cards: owner and card id, SubjectKey, title, note, question snapshot, FSRS state, the CreatedAt and UpdatedAt marks (the newer one wins) FR-44 SR-21
SyncAnswersжурнал ответов с телефона, только добавление: ключ — владелец и AnswerId (cardId:reviewedAt), рейтинг, состояние и параметры FSRS на момент ответаthe phone's answer log, append only: the key is the owner and AnswerId (cardId:reviewedAt), the rating, the state and the FSRS parameters at the time of the answer
SyncStatesпо строке на владельца: состояние аккаунта одним JSON (настройки приложения, настройки и миссии предметов, позиция и удалённые демо) и UpdatedAt с телефонаone row per owner: the account state as one JSON (app settings, subject settings and missions, the demo position and removed demos) and the phone's UpdatedAt
ModelProfiles, ProfileAssignmentsпрофили «роль → модель» и их назначения владельцам (внешний ключ на профиль)"role → model" profiles and their assignments to owners (foreign key to the profile)
AppSettingsнастройки сервера «ключ → значение» с UpdatedAt; сейчас одна —server settings as "key → value" with UpdatedAt; currently one — ios.latestBuild, номер последней сборки iOS (без строки берётся конфигурация), the latest iOS build number (without the row the configuration is used) FR-75.6

Схемой управляют миграции EF Core: при стартеThe schema is managed by EF Core migrations: on startup DatabaseStartup применяет их, а базу «до миграций» один раз доводит и помечает миграциейapplies them, and a "pre-migration" database is brought up to date once and marked with the Baseline. Litestream непрерывно копирует базу в Tigris (потеря — около секунды, история 7 дней), снимки тома Fly хранятся 14 дней; при пропаже тома база восстанавливается из Tigris до запуска сервера.migration. Litestream continuously replicates the database to Tigris (about one second of potential loss, 7 days of history); Fly volume snapshots are kept for 14 days; if the volume is lost, the database is restored from Tigris before the server starts. DR-03 SR-19

Состояния предмета на сервереSubject states on the server

stateDiagram-v2
    state "preparing · 0 · sources" as p0
    state "preparing · 1 · selecting" as p1
    state "preparing · 2 · building" as p2
    [*] --> p0 : POST /subjects
    p0 --> p1 : lesson 1
    p0 --> p2 : lesson N
    p1 --> p2 : pause
    p2 --> ready : validator passed
    p2 --> failed : 3 attempts used or invalid key
    p0 --> failed : sources error
    ready --> p2 : recap
    failed --> p2 : recap resent
    failed --> p0 : POST retry
Subjects.Status и PrepStage. Очередь воркера одна и идёт по порядку; при старте сервера предметы «в подготовке» ставятся в очередь заново. SR-04 SR-12Subjects.Status and PrepStage. The worker has one FIFO queue; on server start, preparing subjects are re-queued. SR-04 SR-12
stateDiagram-v2
    state "no prefetch" as none
    state "stored N+1" as stored
    [*] --> none
    none --> running : POST prefetch, queued
    running --> stored : lesson valid
    running --> none : rejected, error or restart
    stored --> promoted : recap, half or more correct, same stage
    stored --> none : recap below half or stage changed
    running --> promoted : recap waits for it, then matches
    promoted --> none : published as the current lesson
    note right of stored
        a repeated POST answers
        exists, running or skipped
    end note
Заготовка следующего урока. Разбор, пришедший во время заготовки, встаёт за ней в очередь и потом сверяет номер и этап. FR-35 SR-10Prefetch of the next lesson. A recap that arrives while prefetch runs queues behind it and then checks number and stage. FR-35 SR-10

Замеры на реальной моделиMeasurements on a real model

ОперацияOperationВремяTime
Фокусы (sonnet)Focus options (sonnet)5 с5 s
Имя предметаSubject name3 с3 s
ПланPlan6 с6 s
Источники с web searchSources with web search30–120 с30–120 s
Стартовая диагностикаInitial check-in20 с20 s
Следующий урок (5 мин / 10 мин)Next lesson (5 min / 10 min)18–27 с18–27 s
Заготовка → урок после разбораPrefetch → lesson after recap≈ 7 с (глоссарий)≈ 7 s (glossary)
Оценка свободного ответа (haiku)Free-form answer grading (haiku)1.4 с1.4 s
Первая фраза бадди (gemini-3.1-flash-lite, стрим, без рассуждения)Buddy's first sentence (gemini-3.1-flash-lite, streamed, no reasoning)≈ 1 с до первого куска ответа на клиенте: 0,9 с обычный режим, 1,2 с двуязычный (замер через сервер, 30 сентября 2026); полный путь на устройстве — от конца распознавания до озвучки — ещё не мерили≈ 1 s to the first reply chunk at the client: 0.9 s plain, 1.2 s bilingual (measured through the server, 30 September 2026); the full path on a device — end of speech recognition to speech output — not measured yet
4

Генерация контента и моделиContent generation and models

Главная идея: у каждой задачи генерации своя роль, и модель для роли выбирается настройкой, а не кодом. Урок, поиск источников, оценка ответа и реплика бадди могут идти на разных моделях разных провайдеров; все вызовы проходят через один шлюз и одну точку оплаты — OpenRouter. Модель можно заменить без релиза клиента и без деплоя, сравнить модели на реальных уроках тестировщиков и увидеть цену и качество каждой в журнале. SR-17 SR-18 SR-20

The core idea: every generation task has its own role, and the model for a role is chosen by configuration, not by code. The lesson, the source search, answer grading and the buddy's reply can run on different models from different providers; every call goes through one gateway and one bill — OpenRouter. A model can be swapped without a client release or a deploy, models can be compared on testers' real lessons, and the journal shows the cost and quality of each. SR-17 SR-18 SR-20

Роли и моделиRoles and models

РольRoleЧто делает и откуда вызываетсяWhat it does, where it is calledПродProductionПараметрыParameters
Focusтри фокуса темы · POST /subjects/focusthree focus options · POST /subjects/focusgemini-3.1-flash-liteFocusList · medium
Titleкороткое имя предмета · POST /subjects/title; для языков — без моделиshort subject name · POST /subjects/title; language topics skip the modelgemini-3.1-flash-liteTitle · low
Sourcesчетыре источника с доверием, web search (2 запроса) · фоновая задача мастераfour sources with trust, web search (2 uses) · wizard background jobclaude-sonnet-5SourceList · low
Planэтапы плана · POST /subjects/planplan stages · POST /subjects/plangemini-3.1-flash-litePlanList · medium
Replanпересмотр плана на рубеже: этап закрыт, потолок, «Дальше», смена миссии (FR-33) · воркерplan revision at a boundary: stage closed, cap, “Next”, mission change (FR-33) · workergemini-3.1-flash-litePlanRevision · medium
Lessonдиагностика и урок N · воркер (текущий урок и заготовка)Initial check-in and lesson N · worker (current lesson and prefetch)gemini-3.1-flash-liteLesson · medium
Glossary3–6 терминов после урока и связи «термин → шаги-вопросы» (FR-53) · воркер3–6 terms after a lesson and term-to-question links (FR-53) · workergemini-3.1-flash-liteGlossary · 2000
Gradeоценка свободного ответа по смыслу · POST /grade/freemeaning-based grading of a free answer · POST /grade/freeclaude-haiku-4.5Grade · 256
Digestдайджест предмета для разговора · воркер после разбораsubject digest for talks · worker after a recapgemini-3.1-flash-liteтекст · 1200text · 1200
Talkреплика бадди, стриминг по SSE · POST /talks/{id}/turnsthe buddy's reply, streamed over SSE · POST /talks/{id}/turnsgemini-3.1-flash-liteтекст · без рассуждения · 400text · no reasoning · 400
TalkFoldсвёртка старых реплик в две фразыfolds older turns into two sentencesgemini-3.1-flash-liteтекст · 300text · 300
TalkRecordsзаписи из разговора: уверенно / путался, вопрос для повтора и термины глоссария у записи «путался» (FR-53) · POST /talks/{id}/endrecords from a talk: confident / confused, a review question and glossary terms of a “confused” record (FR-53) · POST /talks/{id}/endclaude-haiku-4.5TalkRecords · 3000

«Параметры» — схема структурированного вывода, effort и max_tokens. У ролей мастера и урока лимит 32 000, потому что в него входит thinking. Без настроек код использует Claude: opus-5 для Lesson и Sources, sonnet-5 для Focus, Title, Plan, Replan и Talk, haiku-4.5 для остальных. Раскладка прода задана секретами Fly Teach__Models__<Role>.

"Parameters" is the structured-output schema, effort and max_tokens. Wizard and lesson roles have a 32,000 limit because thinking counts toward it. Without settings the code uses Claude: opus-5 for Lesson and Sources, sonnet-5 for Focus, Title, Plan, Replan and Talk, haiku-4.5 for the rest. The production mapping is set with Fly secrets Teach__Models__<Role>.

Как выбирается модель и транспортHow the model and transport are chosen

flowchart TD
  A["Вызов роли<br/>владелец из GenerationScope"] --> B{"владелец shared<br/>или нет?"}
  B -- да --> E
  B -- нет --> C{"у владельца профиль<br/>и в нём есть роль?"}
  C -- да --> P["модель профиля<br/>журнал: Profile = имя"]
  C -- нет --> E{"Teach__Models__Role<br/>задан?"}
  E -- да --> S["модель из настройки"]
  E -- нет --> D["модель по умолчанию в коде"]
  P --> T{"id начинается<br/>с anthropic/ ?"}
  S --> T
  D --> T
  T -- да --> M["Messages API<br/>Anthropic SDK → OpenRouter"]
  T -- нет --> K["Chat Completions<br/>OpenRouter"]
  M --> R{"схема не поддержана,<br/>JSON сломан или отказ?"}
  K --> R
  R -- нет --> OK["ответ + строка в Generations"]
  R -- да --> F["один повтор без схемы<br/>формат описан словами<br/>Fallback = true"]
  F --> OK
Владелец вызова — владелец предмета (уроки, глоссарий, дайджест), разговора или токена (мастер, оценка). Переопределение Teach__Transports__<Role> сильнее правила по префиксу.
flowchart TD
  A["Role call<br/>owner from GenerationScope"] --> B{"owner is shared<br/>or none?"}
  B -- yes --> E
  B -- no --> C{"owner has a profile<br/>that sets the role?"}
  C -- yes --> P["profile model<br/>journal: Profile = name"]
  C -- no --> E{"Teach__Models__Role<br/>set?"}
  E -- yes --> S["model from settings"]
  E -- no --> D["code default"]
  P --> T{"id starts with<br/>anthropic/ ?"}
  S --> T
  D --> T
  T -- yes --> M["Messages API<br/>Anthropic SDK → OpenRouter"]
  T -- no --> K["Chat Completions<br/>OpenRouter"]
  M --> R{"schema unsupported,<br/>broken JSON or refusal?"}
  K --> R
  R -- no --> OK["answer + row in Generations"]
  R -- yes --> F["one retry without schema<br/>format described in words<br/>Fallback = true"]
  F --> OK
The call owner is the owner of the subject (lessons, glossary, digest), of the talk, or of the token (wizard, grading). A Teach__Transports__<Role> override beats the prefix rule.
Messages APIChat Completions
МоделиModelsanthropic/*все остальные (Gemini, GPT, DeepSeek…)everything else (Gemini, GPT, DeepSeek…)
КлиентClientAnthropic SDK на openrouter.ai/api или напрямую в Anthropic (путь отката)Anthropic SDK against openrouter.ai/api or Anthropic directly (rollback path)свой HttpClient на openrouter.ai/api/v1own HttpClient on openrouter.ai/api/v1
СхемаSchemaoutput_config.formatresponse_format json_schema strict, require_parameters, плагинplugin response-healing
Effort / reasoningadaptive thinking + effortadaptive thinking + effortreasoning.effort по каталогу; «reasoning is mandatory» → один повтор без поляreasoning.effort per catalog; "reasoning is mandatory" → one retry without the field
КэшCachecache_control: методика, контекст предмета, персона бадди, дайджест — только для моделей Anthropic; у Gemini пометка включала явный кэш, добавляла 1,3–1,9 с к первому токену и не давала попаданийmethodology, subject context, buddy persona, digest — Anthropic models only; on Gemini the marker enabled explicit caching, added 1.3–1.9 s to the first token and never hitsession_id = id предмета для липкой маршрутизацииsession_id = subject id for sticky routing
СбоиFailuresSDK, 2 повтораSDK, 2 retries408/429/5xx: 1 с, 2 с, Retry-After до 10 с408/429/5xx: 1 s, 2 s, Retry-After up to 10 s
ПриватностьPrivacyprovider.data_collection: deny в каждом запросеon every request

Каталог моделей OpenRouter загружается при старте: из него берутся лимит вывода и поддержка reasoning, а GET /admin/models показывает предупреждения — модели нет в каталоге или роль со схемой попала на модель без structured outputs. При Teach__Provider=anthropic сервер не стартует, если на какой-то роли не anthropic/*: при откате сначала снимите переопределения ролей.

The OpenRouter model catalog loads at startup: it supplies the output limit and reasoning support, and GET /admin/models shows warnings — a model missing from the catalog, or a schema role on a model without structured outputs. With Teach__Provider=anthropic the server refuses to start if any role is not anthropic/*: when rolling back, unset role overrides first.

Конвейер урокаLesson pipeline

sequenceDiagram
  autonumber
  participant App as Приложение
  participant API
  participant W as LessonWorker
  participant G as Шлюз
  participant M as Модель
  participant DB as SQLite
  App->>API: POST /sessions/{id}/recap (записи)
  API->>DB: записи, решение по этапу (StagePolicy)
  alt заготовка того же этапа и верных ≥ 50 %
    API->>W: Promote
  else
    API->>W: Prepare
  end
  API-->>App: 202 preparing
  W->>DB: источники, этап, длительность, последние 20 записей
  loop до 3 попыток, пока валидатор не пропустит (исключение модели — тоже попытка)
    W->>G: роль Lesson: методика + контекст предмета (кэш) + задача
    G->>M: Messages или Chat Completions
    M-->>G: плоские шаги по схеме
    G->>DB: строка Generations (токены, цена, задержка)
    G-->>W: урок
    W->>W: LessonValidator: шаги, поля, варианты, бюджет длины
  end
  W->>W: заголовок, строка этапа, источник ставит сервер
  W->>DB: урок Ready, LessonRow с моделью и этапом
  W->>G: роль Glossary → слияние в глоссарий
  W->>W: в очередь Digest для разговора
  App->>API: GET /subjects/{id}/lesson (опрос)
  API-->>App: ready + урок + глоссарий
  App->>API: POST /subjects/{id}/prefetch при открытии урока
  API->>W: Prefetch: урок N+1 в PrefetchJson
Три попытки покрывают и отказы валидатора, и исключения модели (сеть, шлюз, провал повтора без схемы); неверный ключ (LlmAuthError) сразу даёт failed. Когда попытки исчерпаны, подготовка в failed, клиент показывает «Повторить», и POST /subjects/{id}/retry ставит урок в очередь заново — в том числе первый (FR-15, FR-37).
sequenceDiagram
  autonumber
  participant App
  participant API
  participant W as LessonWorker
  participant G as Gateway
  participant M as Model
  participant DB as SQLite
  App->>API: POST /sessions/{id}/recap (records)
  API->>DB: records, stage decision (StagePolicy)
  alt prefetch of the same stage and ≥ 50 % correct
    API->>W: Promote
  else
    API->>W: Prepare
  end
  API-->>App: 202 preparing
  W->>DB: sources, stage, duration, last 20 records
  loop up to 3 attempts until the validator passes (a model exception is an attempt too)
    W->>G: Lesson role: methodology + subject context (cached) + task
    G->>M: Messages or Chat Completions
    M-->>G: flat steps by schema
    G->>DB: Generations row (tokens, cost, latency)
    G-->>W: lesson
    W->>W: LessonValidator: steps, fields, options, length budget
  end
  W->>W: server sets title, stage line, source
  W->>DB: lesson Ready, LessonRow with model and stage
  W->>G: Glossary role → merge into glossary
  W->>W: enqueue Digest for talks
  App->>API: GET /subjects/{id}/lesson (polling)
  API-->>App: ready + lesson + glossary
  App->>API: POST /subjects/{id}/prefetch when a lesson opens
  API->>W: Prefetch: lesson N+1 into PrefetchJson
The three attempts cover both validator rejections and model exceptions (network, gateway, failed schema-less retry); an invalid key (LlmAuthError) fails at once. Once the attempts run out, preparation is failed, the client shows "Retry", and POST /subjects/{id}/retry queues the lesson again — the first one included (FR-15, FR-37).
  • Промпт. Системная часть — методика и правила плоского формата; дальше кэшируемый контекст предмета (тема, фокус, миссия, выбранные источники) и задача: для урока 1 — диагностика с бюджетом 5 минут, для урока N — номер, этап плана, бюджет длительности и последние 20 записей. Версия промптов (v1) пишется в урок, журнал и /health. SR-05 SR-06
  • Prompt. The system part is the methodology and the flat-format rules; then the cached subject context (topic, focus, mission, chosen sources) and the task: for lesson 1 the Initial check-in with a 5-minute budget, for lesson N the number, plan stage, duration budget and the last 20 records. The prompt version (v1) is written to the lesson, the journal and /health. SR-05 SR-06
  • Плоская схема. Модель заполняет 13 полей шага; сервер разворачивает их в explain/choice/input/order/free. Заголовок «Урок N · …», строку «этап 0N · фокус» и кикер «зачем» ставит сервер. SR-02
  • Flat schema. The model fills 13 step fields; the server expands them into explain/choice/input/order/free. The "Lesson N · …" title, the "stage 0N · focus" line and the "why" kicker are set by the server. SR-02
  • Валидатор. 1–7 шагов, не больше одного объяснения, хотя бы одна практика; у выбора 2–5 вариантов без дублей, у порядка 3–6 пунктов и перестановка, у свободного ответа 2–5 критериев с ключами; бюджет 1400/2200/4000 символов для 3/5/10 минут. SR-03
  • Validator. 1–7 steps, at most one explanation, at least one practice step; choice has 2–5 options without duplicates, order has 3–6 items and a permutation, free answer has 2–5 criteria with keys; budget 1400/2200/4000 characters for 3/5/10 minutes. SR-03
  • Этапы и заготовка. Этап закрывается, когда последний урок ≥ 75 %, предыдущий ≥ 60 % с первой попытки, либо после шести уроков. Заготовка N+1 отдаётся сразу, если этап не сменился и верных не меньше половины, иначе урок собирается заново. FR-31 FR-35
  • Stages and prefetch. A stage closes when the last lesson is ≥ 75 % and the previous ≥ 60 % first-try correct, or after six lessons. Prefetch N+1 is served at once if the stage did not change and at least half the answers were correct; otherwise the lesson is rebuilt. FR-31 FR-35
  • Источники ищутся один раз при создании предмета (web search через роль Sources) и дальше только передаются в контекст. SR-14
  • Sources are searched once when the subject is created (web search through the Sources role) and are only passed into the context afterwards. SR-14

Журнал, цена и сравнение моделейJournal, cost and model comparison

Каждый вызов — строка в Generations: роль, запрошенная и ответившая модель, транспорт, провайдер, профиль, попытка, запасной путь, задержка и время до первого токена, токены (вход, кэш, выход, reasoning), цена, причина остановки, ошибка и прошёл ли формат. Тексты промптов и ответов не хранятся; строки старше 90 дней удаляются. Цена берётся из usage.cost, а если её нет — дозапрашивается у OpenRouter по id генерации.

Every call is a row in Generations: role, requested and answering model, transport, provider, profile, attempt, fallback, latency and time to first token, tokens (input, cached, output, reasoning), cost, stop reason, error and whether the format passed. Prompt and response texts are not stored; rows older than 90 days are deleted. Cost comes from usage.cost, and if it is missing it is fetched from OpenRouter by generation id.

GET /admin/generations/summary?days=14&profile= сводит по роли и модели: вызовы, доля ошибок и запасного пути, доля годного формата, p50/p95 задержки, токены и цена; для уроков — верно с первой попытки (без диагностики), валидатор с первой попытки и уроков до закрытия этапа (уроки, пропущенные кнопкой «Дальше», не считаются). Так модели сравниваются по педагогическому результату, а не только по цене.

GET /admin/generations/summary?days=14&profile= aggregates by role and model: calls, error and fallback rates, format pass rate, p50/p95 latency, tokens and cost; for lessons also first-try correct (Initial check-in excluded), validator first-pass and lessons per stage (lessons skipped with “Next” are not counted). Models are thus compared on the learning outcome, not only on price.

Почему такая раскладка. 27 сентября 16 моделей сравнили на одном и том же предмете. Gemini 3.1 Flash Lite проходит валидатор и стоит около $0.003 за урок против $0.037 на Opus. Источникам нужен надёжный web search — Sonnet 5 ($0.07 против $0.17 на Opus). Оценка осталась на Haiku 4.5. Не прошли валидатор (пустой пример или запись): GLM 5.3, Qwen 3.7 Plus, MiniMax M3, Sonnet 5, DeepSeek V4 Pro, Gemini 2.5 Flash; GPT-5 mini, Gemini 3 Flash и DeepSeek V4.1 Flash тратят короткие роли на reasoning; Kimi K2.6 работает, но ~97 с на урок. Первый токен бадди на проде — около 3 с.
Why this mapping. On 27 September 16 models were compared on the same subject. Gemini 3.1 Flash Lite passes the validator and costs about $0.003 per lesson versus $0.037 on Opus. Sources need reliable web search — Sonnet 5 ($0.07 versus $0.17 on Opus). Grading stays on Haiku 4.5. Failed the validator (empty example or record): GLM 5.3, Qwen 3.7 Plus, MiniMax M3, Sonnet 5, DeepSeek V4 Pro, Gemini 2.5 Flash; GPT-5 mini, Gemini 3 Flash and DeepSeek V4.1 Flash burn short roles on reasoning; Kimi K2.6 works but takes ~97 s per lesson. The buddy's first token in production is about 3 s.

Как поменять или сравнить моделиHow to change or compare models

# модель роли для всех — секрет Fly, перезапуск без деплоя# a role's model for everyone — a Fly secret, restart without deploy
cd server/Teach.Api
fly secrets set Teach__Models__Lesson=google/gemini-3.1-flash-lite

B=https://teach-tutor-api.fly.dev; T="Authorization: Bearer $ADMIN_TOKEN"
curl -s -H "$T" $B/admin/models                          # модель, транспорт и предупреждения по ролям# model, transport and warnings per role

# профиль: другая модель только для одного тестировщика# a profile: a different model for one tester only
curl -s -X PUT -H "$T" -H 'Content-Type: application/json' \
  -d '{"models":{"Lesson":"anthropic/claude-opus-5"},"note":"Opus vs Gemini"}' $B/admin/profiles/opus-lessons
curl -s -X PUT -H "$T" $B/admin/profiles/opus-lessons/owners/tester:anna

# через пару дней — сравнение профиля с общей раскладкой# a few days later — compare the profile with the shared mapping
curl -s -H "$T" "$B/admin/generations/summary?days=14&profile=opus-lessons"
curl -s -H "$T" "$B/admin/generations/summary?days=14&profile=default"

Изменения профилей действуют со следующего вызова, без перезапуска. Предметы общего владельца shared всегда идут по общей раскладке. Не используйте Sonnet 5 для роли Lesson: он не проходит валидатор.

Profile changes apply from the next call, without a restart. Subjects of the shared owner shared always use the shared mapping. Do not use Sonnet 5 for the Lesson role: it fails the validator.

5

Переезд на новый телефонMoving to a new phone

Телефон в фоне держит на сервере копию повторов и настроек, а новый телефон при первом входе забирает её целиком. Активно одно устройство за раз: карточку при двух устройствах решает последнее изменение. FR-44 FR-74 SR-21

The phone keeps a copy of reviews and settings on the server in the background, and a new phone collects all of it on the first sign-in. One device is active at a time: with two devices, the latest change decides a card. FR-44 FR-74 SR-21

Отправка с телефонаSending from the phone

  • Что уходит.What is sent. Изменённые карточки (метка updatedAt, колонка updated_at, миграция SQLite v4), новые строки журнала ответов, состояние аккаунта одним JSON и удаления своих предметов. Что уже отправлено, хранится в kv под ключом sync: версии отправленных карточек, logWatermark (наибольший отправленный id журнала), последнее отправленное состояние, очередь удалений и флаги known, restored, restoring.Changed cards (the updatedAt mark, the updated_at column, SQLite migration v4), new rows of the answer log, the account state as one JSON, and deletions of own subjects. What has already been sent is kept in kv under the sync key: the versions of sent cards, logWatermark (the largest sent log id), the last sent state, the queue of deletions and the flags known, restored, restoring.
  • Когда.When. Через 5 с после изменения (карточек, прогресса, настроек), при запуске, при возврате в приложение и при уходе в фон (отложенная отправка в свёрнутом приложении не сработала бы); одна отправка за раз, а изменение во время отправки ставит ещё одну. Порядок: удаления → карточки пачками по 500 → ответы пачками по 2000 → состояние → разовая сверка.5 s after a change (cards, progress, settings), at launch, when the app is brought back and when it goes to the background (a delayed send would not fire in a backgrounded app); one send at a time, and a change made during a send queues another one. The order: deletions → cards in batches of 500 → answers in batches of 2000 → state → the one-time reconciliation.
  • Ключ предмета.The subject key. Карточки своего предмета уходят с его серверным id (remoteId) в нижнем регистре в subjectKey, демо — с id демо (en, speak); тем же ключом названы предметы в состоянии аккаунта. Пока серверного id нет, карточки своего предмета ждут в очереди. Предмет, удалённый до того, как пришёл его серверный id, удаляется на сервере, когда id приходит.Cards of an own subject go out with its server id (remoteId) in lower case as subjectKey, demo cards with the demo id (en, speak); the same key names subjects in the account state. Until the server id exists, an own subject's cards wait in the queue. A subject deleted before its server id arrived is deleted on the server when the id arrives.
  • Ошибки.Errors. Сеть, 5xx, 401, 404, 405, 408, 429 — очередь не трогается, повтор позже (404 и 405 так отвечает сервер без маршрутов /sync/*, и снять пачку значило бы потерять данные). 403 — синхронизация на этом запуске выключена: это общий токен сборки; очередь, в том числе удаления предметов, не трогается и уйдёт, когда у телефона будет токен аккаунта. Прочие 4xx — пачка снимается, ошибка идёт в console.warn. Для удаления предмета 404 значит «уже нет» и считается выполненным (поэтому общий токен на удаление получает 403, а не 404).Network, 5xx, 401, 404, 405, 408, 429 — the queue is left alone and retried later (404 and 405 are how a server without the /sync/* routes answers, and dropping the batch would lose data). 403 — sync is off until the next launch: this is the shared build token; the queue, subject deletions included, is kept and goes out once the phone has an account token. Other 4xx — the batch is dropped and the error goes to console.warn. For a subject deletion, 404 means "already gone" and counts as done (which is why the shared token gets 403 on a deletion, not 404).

Состояние аккаунта на сервере (PUT /sync/state) — один JSON; предметы без серверного id в него не попадают, пока его не получат.

The account state on the server (PUT /sync/state) is one JSON; subjects without a server id are not in it until they get one.

{ "v": 1,
  "settings": { "lang", "theme", "reminder", "weekendOff", "cap", "mode" },
  "subjects": { "<subjectKey>": { "cfg": {…}, "mission": { "cur", "hist" }, "language": {…} | null } },
  "demo": { "step": {…}, "removed": ["speak"] } }

Первая выгрузка и сверкаFirst upload and reconciliation

Первый запуск сборки с синхронизацией на базе с данными не находит отметок об отправке, поэтому уходит всё: все карточки, весь журнал, состояние. Затем один раз POST /sync/known-subjects со всеми серверными id предметов телефона: свои предметы на сервере, которых в списке нет, получают DeletedAt, и удаления, сделанные на телефоне до появления DELETE /subjects/{id}, доходят до сервера. Сверка идёт, только когда у всех своих предметов есть серверный id (иначе сервер пометил бы предмет, чья подготовка ещё не вернула id), пропускается, если своих предметов нет (пустой список сервер отклоняет с 400), и не нужна на восстановленном телефоне. Предметы, созданные на сервере за последние 5 минут, сверка не трогает: их POST /subjects мог разминуться с уже отправленным списком. Общие предметы (созданные до входа по Apple) она тоже не трогает: смотрит только предметы владельца. Сверка со старого телефона, давно не запускавшегося, пометит удалёнными предметы, созданные на втором; метка мягкая, админ возвращает её через undelete.

The first launch of a build with sync on a database with data finds no upload marks, so everything goes out: all cards, the whole log, the state. Then, once, POST /sync/known-subjects with all server ids of the phone's subjects: own subjects on the server that are not in the list get DeletedAt, and deletions made on the phone before DELETE /subjects/{id} existed reach the server. The reconciliation runs only when every own subject has a server id (otherwise the server would mark a subject whose preparation has not returned its id yet), is skipped when there are no own subjects (the server rejects an empty list with 400), and is not needed on a restored phone. Subjects created on the server in the last 5 minutes are left alone: their POST /subjects may have crossed a list that was already sent. Shared subjects (created before Apple sign-in) are not touched either: it looks only at the owner's subjects. A reconciliation from an old phone that has not been launched for a long time will mark as deleted the subjects created on the second one; the mark is soft, and the admin reverts it with undelete.

Восстановление на новом телефонеRestoring on a new phone

  • Условие.Condition. Аккаунт вошёл, база открыта, в ней нет своих предметов и ответов, карточки только посеянные демо, флага restored нет, а телефон ещё не отправлял состояние аккаунта и не ждёт отправки удалений (иначе это старый телефон, где удалили все свои предметы, а не новый); либо прошлое восстановление не закончилось (restoring). Тогда DbGate до интро и мастера показывает RestoreScreen с «Восстанавливаю ваши предметы…», пока идёт GET /sync/restore. Отправка на это время выключена: сверка с неполным списком предметов пометила бы остальные удалёнными.The account is signed in, the database is open, it has no own subjects and no answers, its cards are only the seeded demos, there is no restored flag, and the phone has neither sent an account state nor queued deletions (otherwise it is an old phone whose own subjects were all deleted, not a new one); or the previous restore did not finish (restoring). Then DbGate shows RestoreScreen with "Restoring your subjects…" before the intro and the wizard, while GET /sync/restore runs. Sending is off in the meantime: a reconciliation with an incomplete list of subjects would mark the rest as deleted.
  • Шаги.Steps. (1) Пустой ответ — обычный первый вход. (2) Предметы по дате создания получают новые локальные id, remoteId — серверный; среди них и общие предметы, созданные до входа по Apple, если старый телефон перечислил их в состоянии аккаунта, — на телефоне они такие же, как свои; текущий урок готов только у предмета со статусом ready; готовящийся предмет возвращается в этом состоянии, и его дожидается resumePrepare, а упавший остаётся с ошибкой подготовки: на его карточке «Не удалось собрать урок» и кнопка «Повторить»; справочники идут в стор справочников. (3) Состояние: настройки приложения, у предметов режим запоминания, длительность, миссия с историей и язык, позиция демо и удалённые демо вместе с их карточками и справочниками. (4) Карточки: subjectKey переводится в локальный id, карточки неизвестных и удалённых предметов отбрасываются, демо-карточки из ответа заменяют посеянные; карточки и журнал заменяются целиком (replaceAll); журнал берёт все ответы, и ответы отброшенных карточек тоже, поэтому счётчики «за месяц / сегодня» совпадают со старым телефоном, а приток прогноза считается только по живым карточкам (JOIN по карточкам). (5) Всё скачанное считается уже отправленным (markLocalAsUploaded), ставятся restored и known; если есть предметы или ответы, интро не показывается. Прерванное восстановление при следующем запуске идёт заново без дублей.(1) An empty response is an ordinary first sign-in. (2) Subjects get new local ids in creation order, with the server id as remoteId; among them are shared subjects created before Apple sign-in if the old phone listed them in the account state, and on the phone they are the same as own ones; the current lesson is ready only for a subject with the ready status; a preparing subject comes back in that state and resumePrepare waits for it, while a failed one stays with a preparation error: its card shows "Could not build the lesson" and a "Retry" button; references go to the references store. (3) State: the app settings, per subject the recall mode, lesson length, the mission with its history and the language, the demo position and removed demos together with their cards and references. (4) Cards: subjectKey becomes a local id, cards of unknown and deleted subjects are dropped, demo cards from the response replace the seeded ones; cards and the log are replaced wholesale (replaceAll); the log takes every answer, those of dropped cards included, so the "this month / today" counters match the old phone, while the forecast inflow counts only live cards (a JOIN on cards). (5) Everything downloaded counts as already sent (markLocalAsUploaded), restored and known are set; with subjects or answers present, the intro is not shown. An interrupted restore runs again at the next launch without duplicates.
  • Ошибка.Failure. Сеть или сервер: экран «Не удалось связаться с сервером» с «Повторить» и «Начать заново». «Начать заново» помечает всё локальное как отправленное и ведёт в обычный первый вход: серверная копия остаётся прежней, пока на этом телефоне не изменены настройки или предметы, а после первого такого изменения состояние аккаунта с телефона заменяет серверное (побеждает более новое). 403 (общий токен сборки) — обычный первый вход без экрана ошибки, восстановление пробуется при следующем запуске.Network or server: the "Couldn't reach the server" screen with "Try again" and "Start fresh". "Start fresh" marks everything local as sent and leads to the ordinary first sign-in: the server copy stays as it was until settings or subjects are changed on this phone, and after the first such change the phone's account state replaces the server one (the newer wins). 403 (the shared build token) is an ordinary first sign-in without an error screen; the restore is tried again at the next launch.
  • Не переносится.Not carried over. Прерванная сессия урока, отметки «Пройдено сегодня», строка «за неделю: N ошибок закрыто · K темы окрепли» на «Сегодня» (её нет до следующего урока: счётчики added и done не отправляются), итог последнего разговора, неотправленные записи и разговоры старого телефона.An interrupted lesson session, the "Done today" marks, the "this week: N fixed · K topics stronger" line on "Today" (hidden until the next lesson: the added and done counters are not synced), the outcome of the last talk, and unsent records and talks of the old phone.
sequenceDiagram
    participant Old as Old phone
    participant API
    participant DB as Server DB
    participant New as New phone
    participant Local as Local SQLite
    Note over Old,API: 5 s after a change, at launch, on return to the app, on going to the background
    Old->>API: DELETE /subjects/{id} for subjects deleted on the phone
    API->>DB: Subjects.DeletedAt
    Old->>API: POST /sync/cards, up to 500 per batch
    API->>DB: SyncCards, the newer updatedAt wins
    Old->>API: POST /sync/answers, up to 2000 per batch
    API->>DB: SyncAnswers, a repeated answerId is skipped
    Old->>API: PUT /sync/state
    API->>DB: SyncStates, the newer wins
    Note over Old,API: once, when every own subject has a server id
    Old->>API: POST /sync/known-subjects
    API->>DB: other live subjects get DeletedAt
    Note over New,Local: sign-in, empty database, no own subjects
    New->>API: GET /sync/restore
    API->>DB: live own subjects and listed shared ones, references, cards, answers, state
    API-->>New: subjects, cards, answers, state
    New->>Local: subjects with new ids, cards and log, settings
    Note over New,Local: all marked as sent, intro skipped
    Note over New,Local: on network or server error - Try again or Start fresh
Отправка со старого телефона и восстановление на новом. Метки — маршруты, таблицы и состояния из кода. FR-44 FR-74 SR-21Sending from the old phone and restoring on the new one. The labels are routes, tables and states from the code. FR-44 FR-74 SR-21

Правила на сервереRules on the server

  • Владелец.Owner. Данные хранятся по владельцу (apple:*, tester:*) и не видны другим. Общий токен сборки получает 403 { error: "sync requires an account" }: иначе данные разных людей смешались бы у владельца shared. То же для DELETE /subjects/{id}: под общим токеном 403, а не 404, чтобы телефон не снял удаление с очереди. Предметы, созданные до входа по Apple (до 2026-09-11), лежат у владельца shared: GET /sync/restore отдаёт живые из них, если их id — ключ subjects в состоянии этого аккаунта (без строки состояния — ни одного); сверка их не трогает, а удаление пользователем отвечает 404 (такой предмет не вернётся на новом телефоне: состояние его больше не перечисляет).Data is stored per owner (apple:*, tester:*) and is not visible to others. The shared build token gets 403 { error: "sync requires an account" }: otherwise the data of different people would be mixed under the shared owner. The same goes for DELETE /subjects/{id}: 403 under the shared token, not 404, so that the phone does not drop the deletion from its queue. Subjects created before Apple sign-in (before 2026-09-11) belong to the shared owner: GET /sync/restore returns the live ones whose id is a key of subjects in this account's state (none without a state row); the reconciliation does not touch them, and a user's deletion answers 404 (such a subject does not come back on a new phone: the state no longer lists it).
  • Слияние.Merging. Карточку заменяет только версия с более новым updatedAt (равная и старая пропускаются), ответ с известным answerId пропускается, состояние заменяется более новым. Часы телефона задают updatedAt: при одном активном устройстве это безопасно, при двух неверные часы могут оставить старую версию карточки.A card is replaced only by a version with a newer updatedAt (an equal or older one is skipped), an answer with a known answerId is skipped, the state is replaced by a newer one. The phone's clock sets updatedAt: with one active device that is safe, with two a wrong clock may leave an old version of a card.
  • Лимиты.Limits. До 500 карточек и 2000 ответов за вызов, состояние — объект до 256 КБ; больше или не тот формат — 400. Ответ GET /sync/restore растёт с историей: при десятках тысяч ответов понадобится постраничная выдача.Up to 500 cards and 2000 answers per call, the state is an object up to 256 KB; more or a wrong shape is 400. The GET /sync/restore response grows with the history: tens of thousands of answers would need paging.
  • Удалённый предмет.A deleted subject. Мягкое удаление (Subjects.DeletedAt): для пользователя предмета нет в списке и восстановлении (GET /subjects и GET /sync/restore отфильтровывают DeletedAt сами), а урок, разбор и начало разговора отвечают как для чужого (общий Owns). Уже открытый разговор продолжается: он проверяет только владельца разговора (OwnsTalk). Карточки удалённого предмета в SyncCards остаются, но не отдаются. Админ возвращает предмет через undelete, а админский DELETE по-прежнему удаляет физически.A soft delete (Subjects.DeletedAt): for the user the subject is missing from the list and the restore (GET /subjects and GET /sync/restore filter DeletedAt themselves), while the lesson, the recap and the start of a talk answer as if it belonged to someone else (the shared Owns). A talk that is already open keeps working: it checks only the talk's owner (OwnsTalk). The cards of a deleted subject stay in SyncCards but are not returned. The admin brings a subject back with undelete, while the admin DELETE still deletes physically.
Выкатка: сначала сервер с миграцией (старые сборки изменений не замечают: новые маршруты, DeletedAt пуст), затем сборка с синхронизацией; её первый запуск выгружает всё и сверяет удалённые предметы. Пользователь, сменивший телефон до запуска новой сборки на старом, восстановит свои предметы, уроки и справочники (они уже на сервере), но не карточки, ответы и общие предметы, созданные до входа по Apple: их перечисляет только состояние, отправленное новой сборкой.Rollout: first the server with the migration (old builds notice nothing: the routes are new, DeletedAt is empty), then the build with sync; its first launch uploads everything and reconciles deleted subjects. A user who switched phones before the old phone ran the new build restores own subjects, lessons and references (already on the server), but not cards, answers and shared subjects created before Apple sign-in: only the state sent by the new build lists them.
6

API для интеграций и тестовAPI for integrations and tests

База:Base URL: https://teach-tutor-api.fly.dev. Все маршруты, кроме. All routes except /health, /app/latest, /auth/apple, /console/ (страница админки без данных) и документации, требуют токен в заголовке(the admin console page, which holds no data) and the documentation require a token in the Authorization: Bearer <token>: токен аккаунта (после входа через Apple), именной токен тестировщика или общий токен сервера. Маршрутыheader: an account token (after Sign in with Apple), a named tester token, or the shared server token. The /admin/* принимают только общий токен. Токены выдаёт владелец проекта; в документации их нет.routes accept only the shared token. Tokens are issued by the project owner; they are not in the documentation. SR-07 SR-08 SR-15 Интерактивная справка с формами запросов:Interactive reference with request forms: /scalar, машинное описание:; machine-readable description: /openapi/v1.json.

Каждый вызов генерации тратит деньги: предмет с диагностикой и одним уроком стоит порядка 10–20 центов, поиск источников — самая дорогая часть. Для проверки формата ответов достаточно одного предмета; повторные разборы того же урока бесплатны, сервер их не генерирует заново.Every generation call costs money: a subject with an Initial check-in and one lesson costs on the order of 10–20 cents, and source search is the most expensive part. One subject is enough to check response formats; repeated recaps of the same lesson are free, as the server does not regenerate them.

МаршрутыRoutes

Метод и путьMethod and pathНазначениеPurpose
GET /healthСостояние сервера:Server status: { ok, model, prompts }. Без токена.. No token.
GET /app/latestПоследняя выложенная сборка:The latest published build: { ios: { build, url } }: номер из: the number from AppSettings (его ставит(set by PUT /admin/app/latest), без него — из), without it from Teach__App__LatestIosBuild; ссылка из; the link from Teach__App__IosUrl. Без токена; клиент показывает подсказку «Есть сборка N», если номер выше установленного.. No token; the client shows the "Build N is out" nudge if the number is higher than the installed one. FR-08
POST /auth/apple{ identityToken } → { token, owner }: подпись проверяется по ключам Apple, выдаётся токен аккаунта: the signature is verified against Apple's keys, and an account token apple:<sub>. Без токена.is issued. No token. FR-01 SR-08
GET /subjectsсвои предметы владельца токена (под общим токеном — предметы владельца the token owner's own subjects (under the shared token — those of the shared); удалённые пользователем не показываются. owner); subjects deleted by the user are not shown. FR-16 SR-08 SR-21
POST /subjects/focus{ topic } → три фокуса→ three focus options [{ t, d }]. FR-17
POST /subjects/title{ topic } → { title }, короткое имя до 24 символов., a short name of up to 24 characters. FR-10
POST /subjects/sources{ topic, focus } → 202 { jobId, status: "running" }. Поиск идёт в фоне.. The search runs in the background. FR-12 SR-04
GET /subjects/sources/{jobId}{ status: "running" } | { status: "ready", items: [{ id, t, m, trust }] } | { status: "failed", error }.
POST /subjects/plan{ topic, focus, mission } → этапы→ stages [{ n, t, d }]. FR-17
POST /subjectsчерновик предмета → 202subject draft → 202 { subjectId, status: "preparing" }; диагностика готовится в фоне.; the Initial check-in is prepared in the background. FR-13 FR-14
GET /subjects/{id}/lessonстатус подготовки и текущий урок, см.preparation status and the current lesson, see форматыformats. FR-13 FR-32 FR-33 FR-36
PUT /subjects/{id}/mission{ text, editedAt } (1–300 символов, мс эпохи) → 204; новый текст ставит пересмотр этапов после текущего, тот же текст или правка старше последней — 204 без действий; 400 — пустой или длинный текст, 404 — чужой или удалённый предмет.(1–300 characters, epoch ms) → 204; a new text queues a revision of the stages after the current one, the same text or an edit older than the last one gives 204 with no action; 400 for an empty or long text, 404 for someone else’s or a deleted subject. FR-33 SR-22
PUT /subjects/{id}/plan{ baseVersion, current?: { t, d }, ahead: [{ id?, t, d }] } → 200→ 200 { plan, planVersion, planStage }: правка человека — текст текущего этапа и до 8 этапов впереди (t 1–60, d до 140, иначе 400); новые, переписанные и переставленные закрепляются; без изменений — без новой версии; 409 stale с текущим планом.: a human edit — the current stage’s text and up to 8 stages ahead (t 1–60, d up to 140, otherwise 400); new, rewritten and moved stages are pinned; no change — no new version; 409 stale with the current plan. FR-33 SR-23
POST /subjects/{id}/plan/skip{ baseVersion } → 202→ 202 { status: "preparing" }: «Дальше» — готовый урок уходит (Skipped), пересмотр, урок N+1 на новом этапе; 409 stale | preparing | diagnostic.: “Next” — the ready lesson goes away (Skipped), revision, lesson N+1 on the new stage; 409 stale | preparing | diagnostic. FR-33 SR-23
POST /subjects/{id}/plan/revert{ baseVersion } → 200→ 200 { plan, planVersion, planStage }: «Вернуть прежний план» — этапы до последнего изменения моделью, закреплённые; готовый урок не пересобирается; 409 stale | nothing.: “Restore the previous plan” — the stages as before the model’s last change, pinned; the ready lesson is not rebuilt; 409 stale | nothing. FR-33 SR-23
GET /subjects/{id}/plan/historyверсии плана от новых к старымplan versions from newest to oldest [{ version, author, reason, why, at, stageIndex, restoredFrom, stages }]. FR-33 SR-23
GET /subjects/{id}/lessonsвсе уроки предмета по номерамall lessons of the subject by number [{ number, lesson }]; по ним приложение восстанавливает вопросы старых карточек повторов.; the app uses them to restore the questions of old review cards. FR-41
POST /sessions/{id}/recapзаписи разбора → 202recap records → 202 { status: "preparing" | "ready" | "stored" }; для демо-id только; for demo ids only stored. FR-26 FR-30 SR-11
POST /subjects/{id}/prefetchзаготовить следующий урок → 202prefetch the next lesson → 202 { status: "queued" | "exists" | "running" | "skipped" }. FR-35
POST /subjects/{id}/retryповторить упавший урок → 202retry a failed lesson → 202 { status: "preparing" | "ready" }: : failed снова goes back to preparing и в очередь; идущую подготовку не дублирует, готовый урок не трогает. and into the queue; a preparation in progress is not duplicated, a ready lesson is left alone. FR-15 FR-37
DELETE /subjects/{id}удалить свой предмет → 204; мягко, строка остаётся сdelete your own subject → 204; soft: the row stays with DeletedAt. Повтор тоже 204, чужой и общий предмет — 404, общий токен — 403 { error: "sync requires an account" } (телефон оставляет удаление в очереди). Удалённый предмет для пользователя как чужой: нет в списке, урок и начало разговора отвечают 404, разбор только сохраняет записи; уже открытый разговор продолжается.. A repeat is also 204, a stranger's or shared subject is 404, the shared token gets 403 { error: "sync requires an account" } (the phone keeps the deletion queued). A deleted subject is like someone else's for the user: it is not in the list, the lesson and the start of a talk answer 404, the recap only stores the records; a talk that is already open keeps working. FR-72 SR-21
POST /grade/free{ criteria: [{ t, keys }], text, lang } → { hits: boolean[] }. FR-24
POST /subjects/{id}/talksНачать разговор с бадди → 201Start a talk with the buddy → 201 { talkId }. Дайджест предмета собирается, если его ещё нет.. The subject digest is built if it does not exist yet. FR-66
POST /talksРазговор без серверного предмета (демо):A talk without a server-side subject (demo): { topic, focus, mission, title, stage, glossary, records } → 201 { talkId }; дайджест собирается из тела и хранится в разговоре.; the digest is built from the body and stored in the talk. FR-66
POST /talks/{id}/turns{ text, voiceLang? } → text/event-stream: события: events delta (кусок ответа; с(a chunk of the reply; with voiceLang — канал— channel ch: "say" вслух целиком на языке предмета или, spoken aloud in full in the subject's language, or ch: "text" в ленту),, shown in the feed), done (полный текст(the full reply, голосtext, the say, номер реплики),voice text, the turn number), error. Пустой. An empty text — вступление бадди. После /end — 409requests the buddy's opening line. After /end — 409 { error: "talk ended" }. SR-16
POST /talks/{id}/endЗавершить разговор и извлечь записи → 200End the talk and extract the records → 200 { status: "done" | "empty" | "failed", records: [{ index, title, note, ok, step, terms }] } (step и terms опускаются, если их нет; terms — только у «путался»)(step and terms are omitted when there are none; terms only on “confused” records); 409409 { error: "extracting" }.
POST /sync/cards{ cards: [{ cardId, subjectKey, subjectName, title, note, source, ref, fsrs, createdAt, updatedAt, miss? }] } → 200 { stored, skipped }: до 500 карточек за раз, побеждает более новая по updatedAt. Все /sync/* — только токен аккаунта или тестировщика: под общим токеном 403 { error: "sync requires an account" }, неверное тело — 400.: up to 500 cards per call, the newer updatedAt wins. All /sync/* routes need an account or tester token: under the shared token the answer is 403 { error: "sync requires an account" }, a malformed body is 400. FR-44 SR-21
POST /sync/answers{ answers: [{ answerId, cardId, rating, state, due, stability, difficulty, elapsedDays, lastElapsedDays, scheduledDays, learningSteps, reviewedAt }] } → 200 { stored, skipped }: до 2000 за раз, только добавление, повтор answerId пропускается.: up to 2000 per call, append only, a repeated answerId is skipped. FR-44 SR-21
PUT /sync/state{ json, updatedAt } → 200: состояние аккаунта, объект до 256 КБ (UTF-8), сохраняется, только если updatedAt новее.: the account state, an object up to 256 KB (UTF-8), stored only if updatedAt is newer. FR-44 SR-21
POST /sync/known-subjects{ subjectIds: [...] } → 200 { deleted }: разовая сверка. Свои живые предметы вне списка, созданные больше 5 минут назад, получают DeletedAt, число пишется в лог; общие не трогаются. Пустой или нечитаемый список — 400: он не значит «предметов нет».: a one-time reconciliation. Own live subjects outside the list created more than 5 minutes ago get DeletedAt, and the count is logged; shared ones are not touched. An empty or unreadable list is 400: it does not mean "no subjects". FR-44 SR-21
GET /sync/restoreвсё для нового телефона →everything for a new phone → { subjects, cards, answers, state }: свои живые предметы и живые общие, чей id — ключ subjects в состоянии аккаунта (созданы до входа по Apple), по дате создания (план, текущий урок, справочники, статус подготовки), карточки без карточек удалённых предметов, ответы по времени, состояние или null.: own live subjects plus live shared ones whose id is a key of subjects in the account state (created before Apple sign-in), by creation date (plan, current lesson, references, preparation status), cards except those of deleted subjects, answers by time, the state or null. FR-74 SR-21
POST · GET · DELETE /admin/tokensВыдать именной токен тестировщикуIssue a named tester token { name, console? } (значение показывается один раз; console: true — токен QA, вид qa), перечислить с последним использованием, отозвать по id. Выдать и отозвать — только общий токен; перечислить может и токен QA.(the value is shown once; console: true issues a QA token, kind qa), list tokens with their last use, revoke by id. Issuing and revoking take the shared token only; a QA token may list. SR-15 FR-75.7
GET /admin/me{ owner, kind, name, canWrite }: кто вошёл в админку. canWrite — только у общего токена; у токена QA false, и админка прячет всё, что меняет данные. Остальным токенам 403.: who is signed in to the console. Only the shared token has canWrite; a QA token gets false and the console hides everything that changes data. Other tokens get 403. FR-75.7
GET · POST · DELETE /admin/subjects…Предметы владельца:An owner's subjects: ?owner=tester:<name>, без; without owner — последние предметы всех владельцев (— the latest subjects of all owners (limit, по умолчанию 50, не больше 200); удалить предмет по id вместе с уроками, записями, справочниками и разговорами — уборка после смоуков. Журнал генераций остаётся. В списке есть deletedAt — предмет, который удалил пользователь; POST /admin/subjects/{id}/undelete возвращает его владельцу (204). Только общий токен., 50 by default, at most 200); delete a subject by id together with its lessons, records, references and talks — cleanup after smoke runs. The generation journal is kept. The list shows deletedAt — a subject the user deleted; POST /admin/subjects/{id}/undelete brings it back to the owner (204). Shared token only.
GET /admin/overviewЦифры «Пульса» одним запросом заThe «Пульс» (Pulse) numbers in one request for ?days=7 (1–90):(1–90): activeAccounts, newAccounts, lessonsBuilt, lessonsSkipped, talksStarted, calls, errors, fallbacks, costUsd, roles, списки, the failed иand stuck (в подготовке дольше 10 минут, до 50 в каждом) иlists (preparing for more than 10 minutes, up to 50 each) and app — как— as in GET /admin/app/latest. Только общий токен.. Shared token only. FR-75.1
GET /admin/accountsАккаунты — владельцы изAccounts — owners from ApiTokens, Subjects иand SyncStates:: { owner, kind, name, firstSeen, lastSeen, subjects, deletedSubjects, profile },, kind —is apple | tester | shared | other, заходы — по токенам; недавние выше;, visits come from tokens; recent first; ?q= — подстрока владельца,— an owner substring, limit 1–500, по умолчанию 100. Только общий токен.1–500, 100 by default. Shared token only. FR-75.2
GET /admin/accounts/{owner}Карточка аккаунта: токены (как вThe account card: tokens (as in GET /admin/tokens), все предметы с), all subjects with deletedAt, профиль и, the profile and reviews { cards, answers14d, openMistakes, stateUpdatedAt }; неизвестный владелец — 404;; an unknown owner is 404; %2F вin {owner} читается какreads as /. Только общий токен.. Shared token only. FR-75.2
GET /admin/subjects/{id}Предмет изнутри без проверки владельца: все поля, источники, план и история версий, уроки без содержимого (до 500), последние 1000 записей, справочники (до 200), дайджест, заготовка, 100 последних разговоров без реплик, 200 последних строк журнала по предмету. Только общий токен.The subject from inside without the owner check: all fields, sources, the plan and its version history, lessons without content (up to 500), the last 1000 records, references (up to 200), the digest, the prefetch, the last 100 talks without turns, the last 200 journal rows of the subject. Shared token only. FR-75.3
GET /admin/subjects/{id}/lessons/{n}{ info, lesson, generation }: урок целиком (шаги и ответы) и строка журнала, из которой он собран, или: the whole lesson (steps and answers) and the journal row it was built from, or null. Только общий токен.. Shared token only. FR-75.3
GET /admin/talks/{id}Разговор целиком:The whole talk: turns (окно модели),(the model window), olderSummary, transcript,, extractStatus, extract (итог(the /end как есть) иoutcome as is) and digestText демо. Только общий токен.of a demo. Shared token only. FR-75.3
GET · PUT · DELETE /admin/app/latest{ build, source, configBuild, url, updatedAt }, где, where source —is db | config.. PUT {"build": N} сwith Content-Type: application/json пишет N (0–1000000, 0 выключает подсказку) вwrites N (0–1000000, 0 turns the nudge off) to AppSettings и отвечает тем же объектом; другой тип тела, кривой JSON или номер вне диапазона — 400.and answers with the same object; another body type, broken JSON or a number out of range is 400. DELETE — 204, номер снова из конфигурации. Действует без рестарта. Только общий токен.— 204, the number comes from the configuration again. Takes effect without a restart. Shared token only. FR-75.6 FR-08
GET /admin/modelsМодель, транспорт, лимит вывода и предупреждения каталога по ролям;Model, transport, output limit and catalog warnings per role; ?owner= — с учётом профиля владельца.— taking the owner's profile into account. SR-17 SR-20
GET /admin/generationsПоследние вызовы модели:Recent model calls: ?role=&model=&profile=&subject=&ok=&limit=50;; profile — имя илиis a name or default,, subject — id предмета,is a subject id, ok=false — только ошибки, лимит до 500.gives errors only, the limit is up to 500. SR-18 FR-75.5
GET /admin/generations/summaryСводка по ролям и моделям заSummary by role and model for ?days=14: ошибки, запасной путь, формат, p50/p95, токены, цена, для уроков — верно с первой попытки и уроков до закрытия этапа;: errors, fallback path, format, p50/p95, tokens, price; for lessons — correct on the first attempt and lessons until the stage closes; ?profile= фильтрует по профилю.filters by profile. SR-18 SR-20
GET · PUT · DELETE /admin/profiles…PUT /admin/profiles/{name} { models, note }, PUT /admin/profiles/{name}/owners/{owner}, DELETE /admin/profile-owners/{owner}, DELETE /admin/profiles/{name}, GET /admin/profiles;; %2F вin {owner} читается какreads as /. SR-20 FR-75.4

ПотокиFlows

sequenceDiagram
    participant App
    participant API
    participant W as Worker
    participant G as Gateway
    participant M as OpenRouter
    participant DB
    App->>API: POST /subjects/focus, /subjects/title
    API->>G: Focus, Title roles
    G->>M: completion
    API-->>App: 3 focus options, title
    App->>API: POST /subjects/sources
    API-->>App: 202 jobId
    API->>G: Sources role in background, web search
    G->>M: completion with web search
    loop every 3 s
        App->>API: GET /subjects/sources/{jobId}
        API-->>App: running, ready with items, or failed
    end
    App->>API: POST /subjects/plan
    API->>G: Plan role
    API-->>App: plan stages
    App->>API: POST /subjects with sources, plan, duration
    API->>DB: Subjects Preparing, stage 0
    API->>W: enqueue Prepare
    API-->>App: 202 subjectId
    Note over App: wizard closes, Today shows progress
    W->>DB: stage 1, pause, stage 2
    W->>G: Lesson role, Initial check-in, up to 3 attempts
    W->>G: Glossary role
    W->>DB: Ready, lesson, Lessons row, glossary
    loop every 3 s, 5xx tolerated 3 min
        App->>API: GET /subjects/{id}/lesson
        API-->>App: preparing stage N, then ready with lesson
    end
    W->>G: Digest role, for talks
Создание предмета: мастер, фоновый поиск источников, диагностика в очереди воркера. FR-13 FR-17Creating a subject: wizard, background source search, Initial check-in in the worker queue. FR-13 FR-17

Разбор и следующий урок с заготовкой — на диаграмме конвейера урока.

Recap and the next lesson with prefetch are shown in the lesson pipeline diagram.

sequenceDiagram
    participant App
    participant API
    participant G as Gateway
    participant M as OpenRouter
    participant DB
    App->>API: POST /subjects/{id}/talks
    API->>DB: subject must be Ready, else 409
    opt digest is older than the current lesson
        API->>G: Digest role
    end
    API->>DB: insert Talks
    API-->>App: 201 talkId
    App->>API: POST /talks/{id}/turns with text, voiceLang
    API->>G: Talk role, stream
    G->>M: streaming completion
    loop chunks
        M-->>G: delta
        API-->>App: SSE delta, channel say or text
        Note over App: first full sentence goes to TTS at once
    end
    G->>DB: Generations row
    API-->>App: SSE done, reply, say, turn
    opt more than 10 turns kept
        API->>G: TalkFold role
    end
    API->>DB: TurnsJson, OlderSummary
    App->>API: POST /talks/{id}/end
    API->>G: TalkRecords role
    G-->>API: records + steps
    API-->>App: done · records
Реплика разговора по SSE: первое законченное предложение озвучивается до конца стрима; завершение разговора извлекает записи. SR-16A talk turn over SSE: the first complete sentence is spoken before the stream ends; ending the talk extracts the records. SR-16
sequenceDiagram
    participant App
    participant Apple
    participant API
    participant DB
    App->>Apple: Sign in with Apple
    Apple-->>App: user id, identityToken
    App->>API: POST /auth/apple identityToken
    API->>Apple: JWKS keys, cached 12 h
    Note over API: issuer, audience, signature, lifetime
    alt valid
        API->>DB: ApiTokens row, SHA-256 hash, owner apple:sub
        API-->>App: 200 token, owner
        Note over App: token in secure storage
    else invalid
        API-->>App: 401, app keeps the build token
    end
    Note over App: opens teach-accountId.db
    App->>API: any call, Bearer token
    API->>DB: find by hash, reject if revoked
    API-->>App: data scoped to the owner
Вход через Apple и обмен на токен аккаунта. Если обмен не удался, приложение работает на токене сборки. SR-08Sign in with Apple and exchange for an account token. If the exchange fails, the app keeps the build token. SR-08

Сценарий от начала до второго урокаScenario from the start to the second lesson

Так ходит приложение; тем же путём можно прогнать сервер из терминала. Токен положите в переменнуюThis is how the app works; the same path can be used to exercise the server from a terminal. Put the token into the TOKEN.variable.

U=https://teach-tutor-api.fly.dev
H="Authorization: Bearer $TOKEN"

# 1. Здоровье и фокусы
curl -s $U/health
curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/subjects/focus \
  -d '{"topic":"Итальянский для поездки"}'

# 2. Источники: задача + опрос (30–120 с)
JOB=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/subjects/sources \
  -d '{"topic":"Итальянский для поездки","focus":"Первые фразы для поездки"}' | jq -r .jobId)
until curl -s -H "$H" $U/subjects/sources/$JOB | tee /tmp/src.json | grep -q '"ready"'; do sleep 5; done

# 3. План и предмет (источники из мастера передаём в sources — сервер больше их не ищет)
PLAN=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/subjects/plan \
  -d '{"topic":"Итальянский для поездки","focus":"Первые фразы для поездки","mission":"объясниться в кафе и на вокзале"}')
SID=$(jq -n --argjson s "$(jq .items /tmp/src.json)" --argjson p "$PLAN" \
  '{topic:"Итальянский для поездки",title:"Итальянский",focus:"Первые фразы для поездки",
    mission:"объясниться в кафе и на вокзале",sourceIds:[$s[].id],sources:$s,plan:$p,durationMinutes:5}' \
  | curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/subjects -d @- | jq -r .subjectId)

# 4. Диагностика: опрос до ready (≈ 20 с)
until curl -s -H "$H" $U/subjects/$SID/lesson | tee /tmp/l1.json | grep -q '"status":"ready"'; do sleep 3; done
jq '.lesson.steps[] | {type, recTitle}' /tmp/l1.json

# 5. Предзагрузка второго урока, пока «идёт» первый
curl -s -X POST -H "$H" $U/subjects/$SID/prefetch

# 6. Разбор: записи с номером урока → урок 2 (сразу из заготовки или ≈ 25 с)
curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/sessions/$SID/recap \
  -d '{"records":[{"title":"Самооценка ≠ уровень","note":"","ok":true,"stepIndex":1,"lessonNumber":1},
                  {"title":"Вежливое Lei идёт с 3-м лицом","note":"","ok":false,"stepIndex":3,"lessonNumber":1}],
       "durationMinutes":5}'
until curl -s -H "$H" $U/subjects/$SID/lesson | tee /tmp/l2.json | grep -q '"number":2,"lesson"'; do sleep 3; done
jq '{title:.lesson.lessonTitle, level:.lesson.level, prefetched, planStage, planTotal}' /tmp/l2.json

# 7. Оценка свободного ответа по смыслу
curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/grade/free \
  -d '{"criteria":[{"t":"Есть приветствие","keys":["buongiorno"]}],"text":"Buonasera, un caffè per favore","lang":"ru"}'U=https://teach-tutor-api.fly.dev
H="Authorization: Bearer $TOKEN"

# 1. Health and focus options
curl -s $U/health
curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/subjects/focus \
  -d '{"topic":"Итальянский для поездки"}'

# 2. Sources: job + polling (30–120 s)
JOB=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/subjects/sources \
  -d '{"topic":"Итальянский для поездки","focus":"Первые фразы для поездки"}' | jq -r .jobId)
until curl -s -H "$H" $U/subjects/sources/$JOB | tee /tmp/src.json | grep -q '"ready"'; do sleep 5; done

# 3. Plan and subject (pass the wizard's sources in sources — the server does not search for them again)
PLAN=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/subjects/plan \
  -d '{"topic":"Итальянский для поездки","focus":"Первые фразы для поездки","mission":"объясниться в кафе и на вокзале"}')
SID=$(jq -n --argjson s "$(jq .items /tmp/src.json)" --argjson p "$PLAN" \
  '{topic:"Итальянский для поездки",title:"Итальянский",focus:"Первые фразы для поездки",
    mission:"объясниться в кафе и на вокзале",sourceIds:[$s[].id],sources:$s,plan:$p,durationMinutes:5}' \
  | curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/subjects -d @- | jq -r .subjectId)

# 4. Initial check-in: poll until ready (≈ 20 s)
until curl -s -H "$H" $U/subjects/$SID/lesson | tee /tmp/l1.json | grep -q '"status":"ready"'; do sleep 3; done
jq '.lesson.steps[] | {type, recTitle}' /tmp/l1.json

# 5. Prefetch the second lesson while the first one is "in progress"
curl -s -X POST -H "$H" $U/subjects/$SID/prefetch

# 6. Recap: records with the lesson number → lesson 2 (straight from the prefetch or ≈ 25 s)
curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/sessions/$SID/recap \
  -d '{"records":[{"title":"Самооценка ≠ уровень","note":"","ok":true,"stepIndex":1,"lessonNumber":1},
                  {"title":"Вежливое Lei идёт с 3-м лицом","note":"","ok":false,"stepIndex":3,"lessonNumber":1}],
       "durationMinutes":5}'
until curl -s -H "$H" $U/subjects/$SID/lesson | tee /tmp/l2.json | grep -q '"number":2,"lesson"'; do sleep 3; done
jq '{title:.lesson.lessonTitle, level:.lesson.level, prefetched, planStage, planTotal}' /tmp/l2.json

# 7. Grading a free-form answer by meaning
curl -s -X POST -H "$H" -H 'Content-Type: application/json' $U/grade/free \
  -d '{"criteria":[{"t":"Есть приветствие","keys":["buongiorno"]}],"text":"Buonasera, un caffè per favore","lang":"ru"}'
ПолеThe stage в статусеfield is absent in the ready отсутствует (null не сериализуется), поэтому в скриптах проверяйтеstatus (null is not serialized), so in scripts check for "number":N, а не, not "stage":null. Идентификаторы демо-предметов клиента (. The server does not know the ids of the client's demo subjects (en, speak) сервер не знает: разбор по ним только сохраняет записи.): a recap for them only stores the records.
7

АдминкаAdmin console

Адрес —The address is /console/ на том же сервере, неon the same server, not /admin/: под: the API routes live under /admin живут API-маршруты. Пользуется владелец платформы с общим токеном сервера; интерфейс только на русском, рассчитан на компьютер.. It is used by the platform owner with the shared server token; the interface is Russian only and meant for a computer. FR-75

УстройствоHow it is built

  • Статика.Static files. Файлы вFiles in server/Teach.Api/wwwroot/console/:: index.html, console.css, app.js и ES-модулиand ES modules in lib/ (запросы, токен, маршруты, узлы, диалог подтверждения, форматы) и(requests, token, routes, nodes, the confirmation dialog, formats) and sections/ (по модулю на раздел). Без сборки, npm, фреймворков и внешних CDN, шрифты системные; раздаёт(one module per section). No build, npm, frameworks or external CDNs, system fonts; served by UseStaticFiles, выкатывается обычным and shipped with a plain fly deploy.
  • Маршруты в хэше.Hash routes. #/pulse (стартовый),(the start), #/accounts, #/accounts/<owner>, #/subjects/<id>, #/subjects/<id>/lessons/<n>, #/talks/<id>, #/models, #/generations?role=…, #/release; сегменты кодируются; segments are encoded with encodeURIComponent, ссылку на любой экран можно сохранить. Сервер в маршрутах с владельцем заменяет, so a link to any screen can be saved. In routes with an owner the server turns %2F наinto / (Kestrel сам его не раскодирует).(Kestrel does not decode it itself).
  • Вход и токен.Sign-in and token. Токен проверяется запросомThe token is checked with GET /admin/overview?days=1: 401 и 403 — «Токен не подошёл». Он хранится в: 401 and 403 give «Токен не подошёл» (Token rejected). It is kept in localStorage (ключ(key teach-console-token) и уходит только заголовком) and goes only as the Authorization: Bearer на относительные путиheader to relative /admin/… — другие адреса клиент страницы не принимает. «Выйти» стирает токен; 401 в работе стирает его и возвращает на вход.paths — the page client refuses any other address. «Выйти» (Sign out) erases the token; a 401 during work erases it and returns to sign-in.
  • Только чтение (QA).Read-only (QA). Вход принимает общий токен и токен QA (вид qa, выдаётся в «Людях» галочкой «и админка на чтение (QA)»). После входа страница один раз спрашиваетSign-in takes the shared token and a QA token (kind qa, issued in «Люди» with the «и админка на чтение (QA)» box). After sign-in the page asks GET /admin/me; при canWrite: false в шапке плашка «Только чтение», а кнопки и формы записи не рисуются. Это удобство, а не защита: на сервере Caller.CanReadAdmin (общий токен или QA) пускает только к GET-маршрутам once; with canWrite: false the header shows a «Только чтение» badge and write buttons and forms are not drawn. That is convenience, not protection: on the server Caller.CanReadAdmin (shared or QA) opens only the GET routes of /admin/*, все записи по-прежнему требуют IsAdmin (только общий токен) и отвечают QA 403. В остальном API токен QA — обычный тестировщик tester:<имя>., while every write still requires IsAdmin (shared token only) and answers 403 to QA. Elsewhere in the API a QA token is a plain tester tester:<name>. FR-75.7
  • Безопасность страницы.Page security. ДляFor /console middleware доa middleware before UseStaticFiles ставитsets Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none', Referrer-Policy: no-referrer иand Cache-Control: no-cache (после выкладки старые модули не смешиваются с новыми). Узлы строятся только через(after a deploy old modules do not mix with new ones). Nodes are built only with createElement//createTextNode: пользовательский текст не становится разметкой, атрибуты: user text never becomes markup, the on* иand style запрещены,attributes are refused, and href — толькоtakes only #… илиor /…. Страница без токена публична и пуста: данные идут только из. Without a token the page is public and empty: data comes only from /admin/*, которые без токена отвечают 401, токенам тестировщиков и аккаунтов — 403., which answer 401 without a token and 403 to tester and account tokens. SR-07
  • Опасные действия.Dangerous actions. Отзыв токена, возврат и полное удаление предмета («Вернуть», «Удалить совсем»), снятие и удаление профиля, смена и сброс номера сборки идут черезRevoking a token, restoring and deleting a subject for good («Вернуть» (Restore), «Удалить совсем» (Delete for good)), unassigning and deleting a profile, setting and resetting the build number go through <dialog id="confirm"> с описанием последствий; удаление предмета и профиля включается после ввода названия. Смена экрана закрывает открытый диалог без действия.with a description of the consequences; deleting a subject or a profile is enabled after typing its name. Leaving the screen closes an open dialog without acting. FR-75
  • Время и числа.Time and numbers. Даты — местное время браузера (время без зоны из SQLite считается UTC), деньги — USD с центами, дроби — с запятой.Dates are in the browser's local time (SQLite times without a zone are read as UTC), money in USD with cents, fractions with a decimal comma.

РазделыSections

ЭкранScreenЧто показывает и откудаWhat it shows and from where
#/pulse«Пульс» за 1, 7, 14 или 30 дней: активные и новые аккаунты, уроки и пропуски, разговоры, вызовы модели и расход по ролям, предметы «сбой» и застрявшие в подготовке дольше 10 минут, номер сборки и его источник —«Пульс» (Pulse) for 1, 7, 14 or 30 days: active and new accounts, lessons and skips, talks, model calls and spend per role, failed subjects and subjects stuck in preparation for more than 10 minutes, the build number and its source — GET /admin/overview. FR-75.1
#/accounts«Люди»: список владельцев («Люди» (People): the owner list (GET /admin/accounts?limit=200&q=), выдача токена тестировщику (), issuing a tester token (POST /admin/tokens, значение видно один раз), карточка аккаунта (, the value is visible once), the account card (GET /admin/accounts/{owner}): токены и отзыв, предметы с «Вернуть» и «Удалить совсем», профиль, сводка повторов.): tokens and revocation, subjects with «Вернуть» (Restore) and «Удалить совсем» (Delete for good), profile, review summary. FR-75.2
#/subjects/<id>«Предмет изнутри»: поля, план с версиями, уроки, записи (записи разговоров — группой «Из разговоров»), справочники, дайджест, заготовка, разговоры, вызовы модели («Предмет изнутри» (Subject inside): fields, the plan with versions, lessons, records (talk records as the «Из разговоров» (From talks) group), references, digest, prefetch, talks, model calls (GET /admin/subjects/{id}); урок по шагам и «Сырой JSON» (); a lesson by steps and «Сырой JSON» (Raw JSON) (…/lessons/{n}); разговор (); a talk (GET /admin/talks/{id}). Поле «id предмета» в шапке открывает предмет по Guid.). The «id предмета» (subject id) field in the header opens a subject by its Guid. FR-75.3
#/models«Модели и профили»: раскладка по ролям («Модели и профили» (Models and profiles): the per-role mapping (GET /admin/models?owner=), профили и назначения (), profiles and assignments (/admin/profiles…, /admin/profile-owners/{owner}); ошибки сервера — у формы, 409 при удалении — со списком владельцев.); server errors at the form, a 409 on delete with the list of owners. FR-75.4
#/generations«Журнал генераций»: сводка («Журнал генераций» (Generation journal): the summary (GET /admin/generations/summary?days=&profile=) и строки () and rows (GET /admin/generations?role=&model=&profile=&subject=&ok=false&limit=), строка раскрывается во все поля.), a row expands into all fields. FR-75.5
#/release«Выпуск»: номер последней сборки, источник, запасное значение и ссылка («Выпуск» (Release): the latest build number, its source, the fallback and the link (GET /admin/app/latest), «Поставить» (), «Поставить» (Set) (PUT) и «Сбросить к конфигурации» () and «Сбросить к конфигурации» (Reset to configuration) (DELETE).). FR-75.6 FR-08

Номер последней сборкиThe latest build number

GET /app/latest берёт номер из таблицыtakes the number from the AppSettings (ключtable (key ios.latestBuild), а если строки нет — из), and if there is no row, from Teach__App__LatestIosBuild (запасное значение; секрет на Fly остаётся). Пишут номер раздел «Выпуск» и(the fallback; the Fly secret stays). The number is written by «Выпуск» (Release) and by scripts/release-ios.sh N --set-latest черезthrough PUT /admin/app/latest {"build": N}; действует сразу, без рестарта.; it takes effect at once, without a restart. Сразу после деплоя этой версии источник — «конфигурация» (текущее значение секрета), пока номер впервые не поставятRight after the deploy that brings this version, the source is «конфигурация» (configuration), the current secret value, until the number is first set with release-ios.sh N --set-latest или «Поставить»; после этого, пока в базе есть номер,or «Поставить» (Set); after that, while the database holds a number, fly secrets set Teach__App__LatestIosBuild на подсказку не влияет и только перезапускает машину.no longer changes the nudge and only restarts the machine. FR-75.6

Локальный стенд и тестыLocal stand and tests

КонфигурацияThe teach-api-console вconfiguration in .claude/launch.json поднимает сервер на заглушке модели (порт 5181, своя базаstarts the server on the model stub (port 5181, its own database server/Teach.Api/console-local.db);); scripts/console-seed.sh наполняет её тестовыми данными, только для localhost. Тесты модулей страницы:fills it with test data, localhost only. Tests of the page modules: TZ=UTC node --test server/console-tests/*.test.mjs; маршруты, доступ и заголовки; routes, access and headers of /console проверяют тесты сервера.are covered by the server tests.

8

Форматы данныхData formats

Статус урокаLesson status

{ "status": "preparing", "stage": 0 | 1 | 2, "number": 2 }        // ищу источники → отбираю → собираю
{ "status": "failed",    "stage": 2, "number": 2 }
{ "status": "ready", "number": 2, "planStage": 0, "planTotal": 5,
  "prefetched": true,      // урок пришёл из заготовки
  "prefetchReady": false,  // заготовка следующего уже лежит на сервере
  "plan": [ { "n", "t", "d", "id": "s3", "by": "model" } ],  // этапы текущей версии: id, by = wizard | model | user
  "planVersion": 3,
  "planChange": { "version", "reason": "StageClosed", "why", "at", "added", "changed", "removed" },  // последнее изменение моделью; added и changed — id, removed — названия
  "planRevertable": true,  // есть что вернуть кнопкой «Вернуть прежний план»
  "stageCloseAt": 0.75,  // урок закроет этап при доле верных ≥ 0.75; 0 — при любом; нет поля — не закроет
  "lesson": { … }, "references": [ { "id", "group", "title", "updatedAfter", "rows": [ { "sec"?, "k", "v", "links"?: [ { "lesson", "step" } ] } ] } ] }{ "status": "preparing", "stage": 0 | 1 | 2, "number": 2 }        // searching sources → selecting → building
{ "status": "failed",    "stage": 2, "number": 2 }
{ "status": "ready", "number": 2, "planStage": 0, "planTotal": 5,
  "prefetched": true,      // the lesson came from the prefetch
  "prefetchReady": false,  // the prefetch of the next lesson is already stored on the server
  "plan": [ { "n", "t", "d", "id": "s3", "by": "model" } ],  // stages of the current version: id, by = wizard | model | user
  "planVersion": 3,
  "planChange": { "version", "reason": "StageClosed", "why", "at", "added", "changed", "removed" },  // last model change; added and changed are ids, removed are titles
  "planRevertable": true,  // there is something to bring back with “Restore the previous plan”
  "stageCloseAt": 0.75,  // the lesson closes the stage at a correct rate ≥ 0.75; 0 — at any; no field — it will not
  "lesson": { … }, "references": [ { "id", "group", "title", "updatedAfter", "rows": [ { "sec"?, "k", "v", "links"?: [ { "lesson", "step" } ] } ] } ] }

Урок и шагиLesson and steps

{ "name": "Итальянский", "level": "этап 01 · Первые фразы для поездки",
  "lessonTitle": "Урок 2 · Карта первых слов", "diagnostic": false,
  "steps": [
    { "type": "explain", "why": "миссия · …", "title": "…", "paras": ["…","…"], "example": "…", "source": "Название, раздел" },
    { "type": "choice",  "prompt": "…", "options": ["…"], "correct": 1,          "explain": "…", "recTitle": "…", "recNote": "…" },
    { "type": "input",   "prompt": "…", "placeholder": "…", "tokens": [["un","uno"],["per favore"]], "answer": "…", "voice": "…", "explain": "…", "recTitle": "…", "recNote": "…" },
    { "type": "order",   "prompt": "…", "items": ["…"], "correct": [1,0,2],     "explain": "…", "recTitle": "…", "recNote": "…" },
    { "type": "free",    "prompt": "…", "criteria": [{ "t": "…", "keys": ["…"] }], "explain": "…", "recTitle": "…", "recNote": "…" }
  ] }
  • correct: -1 у выбора — калибровка без правильного ответа (бейдж «ПРИНЯТО»); пустойon a choice step means calibration without a correct answer (the "ACCEPTED" badge); an empty tokens у ввода принимает любой текст.on an input step accepts any text.
  • source может заканчиваться на «· доверие среднее/низкое» — клиент показывает это предупреждением; «высокое» сервер вырезает.may end with «· доверие среднее/низкое» ("· trust medium/low"; the client parser also accepts the English form) — the client shows it as a warning ("medium trust" / "low trust"); the server strips «доверие высокое» ("high trust").
  • recTitle иand recNote — запись об усвоенном: она становится карточкой повтора и уходит в разбор.are the learning record of what was learned: it becomes a review card and goes into the recap.

Черновик предметаSubject draft

{ "topic": "…", "title": "…", "focus": "…", "mission": "…",
  "sourceIds": ["src-0", "src-2"], "sources": [ { "id", "t", "m", "trust": "high"|"mid"|"low" } ],
  "plan": [ { "n": "01", "t": "…", "d": "…" } ],   // любое число этапов
  "durationMinutes": 3 | 5 | 10 }{ "topic": "…", "title": "…", "focus": "…", "mission": "…",
  "sourceIds": ["src-0", "src-2"], "sources": [ { "id", "t", "m", "trust": "high"|"mid"|"low" } ],
  "plan": [ { "n": "01", "t": "…", "d": "…" } ],   // any number of stages
  "durationMinutes": 3 | 5 | 10 }
9

Локальный запускRunning locally

СерверServer

cd server/Teach.Api
export OPENROUTER_API_KEY=…           # или ANTHROPIC_API_KEY; без ключей — заглушка
dotnet run                            # http://0.0.0.0:5080, база teach.db рядом
curl localhost:5080/healthcd server/Teach.Api
export OPENROUTER_API_KEY=…           # or ANTHROPIC_API_KEY; without keys — the stub
dotnet run                            # http://0.0.0.0:5080, database teach.db next to it
curl localhost:5080/health

Настройки через переменныеSettings via Teach__*:variables: ApiToken (пустой — открытый режим только локально),(empty — open mode, local only), Db, Model (auto/gateway/stub), Provider (openrouter/anthropic), Models__<Role>, Transports__<Role>, StageDelayMs, DurationMinutes, ShutdownSeconds.

КлиентClient

cd mobile && npm install
EXPO_PUBLIC_AUTH_LOCAL=1 EXPO_PUBLIC_VOICE_SIM=1 npx expo start --web      # быстрый просмотр в браузере
EXPO_PUBLIC_CONTENT_URL=http://<ip>:5080 EXPO_PUBLIC_CONTENT_TOKEN=… npx expo start   # с локальным серверомcd mobile && npm install
EXPO_PUBLIC_AUTH_LOCAL=1 EXPO_PUBLIC_VOICE_SIM=1 npx expo start --web      # quick preview in a browser
EXPO_PUBLIC_CONTENT_URL=http://<ip>:5080 EXPO_PUBLIC_CONTENT_TOKEN=… npx expo start   # with a local server

Флаги:Flags: EXPO_PUBLIC_AUTH_LOCAL — вход локальной сессией без провайдера;— sign in with a local session without a provider; EXPO_PUBLIC_VOICE_SIM — симуляция распознавания;— simulated speech recognition; EXPO_PUBLIC_CONTENT_URL иand EXPO_PUBLIC_CONTENT_TOKEN — сервер, без них мастер работает на локальной заглушке.— the server; without them the wizard runs on the local stub.

10

Деплой и релизDeployment and release

Сервер на Fly.ioServer on Fly.io

cd server/Teach.Api                   # здесь Dockerfile и fly.toml
fly deploy --remote-only
fly logs --app teach-tutor-apicd server/Teach.Api                   # Dockerfile and fly.toml are here
fly deploy --remote-only
fly logs --app teach-tutor-api

Секреты:Secrets: OPENROUTER_API_KEY (на время перехода и(plus ANTHROPIC_API_KEY — путь отката) иduring the transition, as the rollback path) and Teach__ApiToken черезvia fly secrets set. База на томе. The database is on the teach_data вvolume in /data. Деплой безопасен посреди генерации: воркер дорабатывает урок при живом сервере, потом машина перезапускается (≈ 10 с паузы), незавершённые предметы ставятся в очередь заново. Отдельного домена не нужно: адрес. Deploying in the middle of a generation is safe: the worker finishes the lesson while the server stays alive, then the machine restarts (≈ 10 s pause), and unfinished subjects are queued again. No separate domain is needed: Fly provides the teach-tutor-api.fly.dev с HTTPS даёт Fly.address with HTTPS.

iOS в TestFlightiOS to TestFlight

cd mobile/ios
export EXPO_PUBLIC_CONTENT_URL=https://teach-tutor-api.fly.dev EXPO_PUBLIC_CONTENT_TOKEN=…
xcodebuild -workspace Teach.xcworkspace -scheme Teach -configuration Release -sdk iphoneos \
  -destination 'generic/platform=iOS' -archivePath build-device/Teach.xcarchive \
  -allowProvisioningUpdates DEVELOPMENT_TEAM=… archive
xcodebuild -exportArchive -archivePath build-device/Teach.xcarchive \
  -exportOptionsPlist ../ExportOptions.plist -exportPath build-device/export -allowProvisioningUpdates

После выгрузки задать номер для подсказки об обновлении:After the upload, set the number for the update nudge: scripts/release-ios.sh N --set-latest или раздел «Выпуск» админки (or the console «Выпуск» (Release) section (/console/#/release). Номер пишется в базу через). The number is written to the database through PUT /admin/app/latest и действует сразу, без рестарта; тестеры на предыдущих сборках увидят «Есть сборка N». Скрипт ставит номер, только если архив сборки N есть вand takes effect at once, without a restart; testers on earlier builds will see "Build N is out". The script sets the number only if the build N archive is in ~/TeachArchives, берёт общий токен из, takes the shared token from server/.secrets/api-token и передаёт его curl через stdin (в аргументах и логах его нет), при VPN ходит на Fly черезand passes it to curl via stdin (it is not in the arguments or logs), goes to Fly via --resolve, затем проверяетwhen a VPN is on, then checks /app/latest.. TEACH_API принимается только сis accepted only with --set-latest, и не-Fly адрес требует своего, and a non-Fly address needs its own TEACH_TOKEN_FILE; прежний; the former --stage-latest печатает подсказку проprints a hint about --set-latest и выходит с кодом 2. Секретand exits with code 2. The Teach__App__LatestIosBuild остаётся запасным значением: он действует, только пока номера в базе нет (свежая база или сброс в «Выпуске»).secret stays as the fallback: it applies only while the database has no number (a fresh database or a reset in «Выпуск»). Сразу после деплоя этой версии источник — «конфигурация» (текущее значение секрета), пока номер впервые не поставятRight after the deploy that brings this version, the source is «конфигурация» (configuration), the current secret value, until the number is first set with release-ios.sh N --set-latest или «Поставить»; после этого, пока в базе есть номер,or «Поставить» (Set); after that, while the database holds a number, fly secrets set Teach__App__LatestIosBuild на подсказку не влияет и только перезапускает машину.no longer changes the nudge and only restarts the machine. Перед сборкой поднятьBefore the build, bump ios.buildNumber вin app.json иand CFBundleVersion в Info.plist. Адрес сервера и токен зашиваются в бандл на этапе сборки.in Info.plist. The server address and token are baked into the bundle at build time.

11

Тесты и наблюдениеTests and monitoring

cd server && dotnet test                 # 275 тестов: валидатор, этапы, API-сценарии, миграции, токены, разговор, шлюз, журнал, профили, синхронизация
cd mobile && npx tsc --noEmit && npx jest # 407 тестов: движок сессии, FSRS и режимы, сторы, HTTP-контракт, прогноз, виджет, озвучка, отправка и восстановлениеcd server && dotnet test                 # 275 tests: validator, stages, API scenarios, migrations, tokens, talk, gateway, journal, profiles, sync
cd mobile && npx tsc --noEmit && npx jest # 407 tests: session engine, FSRS and modes, stores, HTTP contract, forecast, widget, speech output, sending and restoring

Серверные API-тесты идут на заглушке модели и проходят весь путь: создание предмета, диагностика, разбор, этапы по результатам, предзагрузка, повторный разбор. В логах Fly видны вызовы модели с токенами и временем, готовность уроков, заготовки и продвижения, дожидание при остановке. Состав тестов —The server API tests run on the model stub and cover the whole path: subject creation, Initial check-in, recap, stages by results, prefetch, repeated recap. The Fly logs show model calls with tokens and timing, lesson readiness, prefetches and stage advances, and draining on shutdown. The test suite is described in NF-05; ручная проверка —; manual testing — тестовая документация QAQA test documentation, тест-кейсы привязаны к требованиям во FRD., whose test cases are linked to the requirements in the FRD.

12

СсылкиLinks

  • Функциональные требованияFunctional requirements — что должна делать система, по экранам и сущностям, со статусами.— what the system must do, by screen and entity, with statuses.
  • Тестовая документация QAQA test documentation — test cases для тестировщиков (docx, русский и английский в одном файле): подготовка, smoke test, кейсы по областям, шаблон bug report; генерируется из— test cases for testers (docx, Russian and English in one file): setup, smoke test, cases by area, bug report template; generated from docs/qa/make_qa_doc.py.
  • АдминкаAdmin console — шесть разделов под общим токеном, для QA — на чтение (— six sections under the shared token, read-only for QA (раздел 7section 7).).
  • Scalar — интерактивная справка API с примерами запросов;— interactive API reference with request examples; OpenAPI JSON.
  • РепозиторийRepository — docs/ai-content.md про генерацию контента, README клиента и сервера.on content generation, client and server READMEs.
Страница лежит вThis page lives in server/Teach.Api/wwwroot/docs/ и выкатывается вместе с сервером. Секретов в ней нет; токен API передаётся отдельно.and ships together with the server. It contains no secrets; the API token is provided separately.