Как устроен мобильный репетитор 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.
Обзор и архитектура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.
Клиент никогда не держит ключей модели и не планирует обучение: он ходит только в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Поток одного предметаFlow of one subject
Клиент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 functionsproject,rubberband,decideFlingindeck.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, souseTodayholds 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, подложка примера). Разбор ответа — лист снизу (reanimatedSlideInDown/FadeInпри «Уменьшить движение»), итог —StageRing120 pt с хаптиком при замыкании этапа. В разговоре кольцо микрофона следует за громкостью:volumechangeиз expo-speech-recognition →voiceLevel(0…1) →useTalk().level. В тёмной теме плитка героя приглушена к фону и без свечения.: the lesson, recap and talk take the subject’s color from theSubjectTintcontext (segments, thesubjectcapsule, the example backdrop). The answer recap is a bottom sheet (reanimatedSlideInDown,FadeInunder Reduce Motion), the recap has a 120 ptStageRingwith a haptic when a stage closes. In the talk, the mic ring follows loudness:volumechangefrom 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(Formin a full-sheetHost, theme from the app). The primitives live insrc/ui/settings:SettingsFormSwifton iOS andSettingsFormRNfor the web preview, with a shared contract intypes.ts. Deleting and signing out useconfirmDestructive(a systemAlert). TheremindersOnswitch 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 ownios.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 Groupgroup.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 openteach://. Подробности отладки —. Debugging details are inmobile/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. Подробности — в разделе «Переезд на новый телефон».— thesrc/features/syncmodule: 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 inkvunder thesynckey. On a new phoneDbGateshowsRestoreScreenbefore the intro and builds the database fromGET /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 toexpo-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 — SwiftUIListиз@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первой). Перед «Дальше», возвратом, разбором и обновлением плана очередь отправляется первой. 409staleкладёт план из ответа и один раз показывает в листе «План успел обновиться…» (planConflict). «Этот этап я уже знаю — дальше» (skipStage) — только в сети, на диагностике и при подготовке недоступно (skipBlock); после 202 сохранённая сессия урока стирается, предмет переходит в подготовку без записей и ждёт урок N+1. «Вернуть прежний план» показывается поplanRevertable; «История изменений» — листapp/plan-history/[id], только в сети.: thesrc/features/planmodule. The “Plan” sheet (app/plan/[id], a button in thePlanBlockon the “Subjects” tab) shows the stages, the current and passed ones, the “why” of the last revision and a stage-closing caption fromstageCloseAt; 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 theoutboxqueue 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 SwiftUIListfrom@expo/uiin edit mode (List.ForEachwithonDelete/onMove,PlanEditorSwift), on the web preview an RN list with ↑ / ↓ / ✕ buttons (PlanEditorRN); the draft is a separateusePlanDraftstore, a stage’s text is edited in theapp/plan-stage/[slot]sheet. “Done” changes the plan in the store at once (editPlanpins stages the same way the server does, by LCS) and puts{ kind: "plan", baseVersion, current?, ahead, at }into the sameoutboxqueue; unsent plan edits merge into one (the latest state with the firstbaseVersion). Before “Next”, restore, a recap and a plan refresh the queue is sent first. A 409stalestores 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 onplanRevertable; “Change history” is theapp/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, clearSessionsrc/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-29flowchart 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-38stateDiagram-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 notetalkMachine.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-66stateDiagram-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Карточки повторов в 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.Сервер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и на расхождение отвечают 409staleс текущим планом. Правка или возврат текста текущего этапа сбрасывает заготовку и дайджест; заготовка, собранная, пока версия плана сменилась, отбрасывается. Пропущенные уроки не входят в сводку генераций.The subject plan is stored as versions inPlanVersions: each stage has an id (“s1”, “s2”…, the counter is never reused) and a text authorby. 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 aMissionChangedboundary: 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 (planChangewith a one-line “why”) andstageCloseAt, the rate at which the ready lesson closes the stage. Older subjects get version 1 at server start. The person edits the plan withPUT /subjects/{id}/plan(PlanBook.ApplyUserEdit: new, rewritten and moved stages — outside the longest common subsequence — getby = user, aUserEditversion), closes a stage themselves (POST …/plan/skip: the lesson is markedSkipped, aStageSkippedboundary, aPreparejob) 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; aRevertversion withRestoredFrom). All three checkbaseVersionand answer a mismatch with 409staleand 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_tokens400 с учётом 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 statusemptywith no model call. The call is synchronous and bounded by 90 seconds; an error or timeout givesfailed(terminal: a repeated/enddoes not call the model). The extraction status moves torunningby a conditional update, so a parallel call gets 409, andrunningolder than two minutes counts as abandoned and is restarted. Records are saved withOrigin = talkandLessonNumber = 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/endreturns 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"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"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 retrySubjects.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-12stateDiagram-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Замеры на реальной моделиMeasurements on a real model
| ОперацияOperation | ВремяTime |
|---|---|
| Фокусы (sonnet)Focus options (sonnet) | 5 с5 s |
| Имя предметаSubject name | 3 с3 s |
| ПланPlan | 6 с6 s |
| Источники с web searchSources with web search | 30–120 с30–120 s |
| Стартовая диагностикаInitial check-in | 20 с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 |
Генерация контента и модели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/focus | gemini-3.1-flash-lite | FocusList · medium |
| Title | короткое имя предмета · POST /subjects/title; для языков — без моделиshort subject name · POST /subjects/title; language topics skip the model | gemini-3.1-flash-lite | Title · low |
| Sources | четыре источника с доверием, web search (2 запроса) · фоновая задача мастераfour sources with trust, web search (2 uses) · wizard background job | claude-sonnet-5 | SourceList · low |
| Plan | этапы плана · POST /subjects/planplan stages · POST /subjects/plan | gemini-3.1-flash-lite | PlanList · medium |
| Replan | пересмотр плана на рубеже: этап закрыт, потолок, «Дальше», смена миссии (FR-33) · воркерplan revision at a boundary: stage closed, cap, “Next”, mission change (FR-33) · worker | gemini-3.1-flash-lite | PlanRevision · medium |
| Lesson | диагностика и урок N · воркер (текущий урок и заготовка)Initial check-in and lesson N · worker (current lesson and prefetch) | gemini-3.1-flash-lite | Lesson · medium |
| Glossary | 3–6 терминов после урока и связи «термин → шаги-вопросы» (FR-53) · воркер3–6 terms after a lesson and term-to-question links (FR-53) · worker | gemini-3.1-flash-lite | Glossary · 2000 |
| Grade | оценка свободного ответа по смыслу · POST /grade/freemeaning-based grading of a free answer · POST /grade/free | claude-haiku-4.5 | Grade · 256 |
| Digest | дайджест предмета для разговора · воркер после разбораsubject digest for talks · worker after a recap | gemini-3.1-flash-lite | текст · 1200text · 1200 |
| Talk | реплика бадди, стриминг по SSE · POST /talks/{id}/turnsthe buddy's reply, streamed over SSE · POST /talks/{id}/turns | gemini-3.1-flash-lite | текст · без рассуждения · 400text · no reasoning · 400 |
| TalkFold | свёртка старых реплик в две фразыfolds older turns into two sentences | gemini-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}/end | claude-haiku-4.5 | TalkRecords · 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 --> OKTeach__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 --> OKTeach__Transports__<Role> override beats the prefix rule.| Messages API | Chat Completions | |
|---|---|---|
| МоделиModels | anthropic/* | все остальные (Gemini, GPT, DeepSeek…)everything else (Gemini, GPT, DeepSeek…) |
| КлиентClient | Anthropic 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 |
| СхемаSchema | output_config.format | response_format json_schema strict, require_parameters, плагинplugin response-healing |
| Effort / reasoning | adaptive thinking + effortadaptive thinking + effort | reasoning.effort по каталогу; «reasoning is mandatory» → один повтор без поляreasoning.effort per catalog; "reasoning is mandatory" → one retry without the field |
| КэшCache | cache_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 hit | session_id = id предмета для липкой маршрутизацииsession_id = subject id for sticky routing |
| СбоиFailures | SDK, 2 повтораSDK, 2 retries | 408/429/5xx: 1 с, 2 с, Retry-After до 10 с408/429/5xx: 1 s, 2 s, Retry-After up to 10 s |
| ПриватностьPrivacy | provider.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 в PrefetchJsonLlmAuthError) сразу даёт 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 PrefetchJsonLlmAuthError) 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.
Как поменять или сравнить модели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.
Переезд на новый телефон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 (theupdatedAtmark, theupdated_atcolumn, 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 inkvunder thesynckey: the versions of sent cards,logWatermark(the largest sent log id), the last sent state, the queue of deletions and the flagsknown,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 assubjectKey, 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 toconsole.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 norestoredflag, 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). ThenDbGateshowsRestoreScreenwith "Restoring your subjects…" before the intro and the wizard, whileGET /sync/restoreruns. 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 asremoteId; 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 thereadystatus; a preparing subject comes back in that state andresumePreparewaits 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:subjectKeybecomes 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),restoredandknownare 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: theaddedanddonecounters 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Правила на сервере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 thesharedowner. The same goes forDELETE /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 thesharedowner:GET /sync/restorereturns the live ones whose id is a key ofsubjectsin 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 newerupdatedAt(an equal or older one is skipped), an answer with a knownanswerIdis skipped, the state is replaced by a newer one. The phone's clock setsupdatedAt: 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. TheGET /sync/restoreresponse 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 /subjectsandGET /sync/restorefilterDeletedAtthemselves), while the lesson, the recap and the start of a talk answer as if it belonged to someone else (the sharedOwns). A talk that is already open keeps working: it checks only the talk's owner (OwnsTalk). The cards of a deleted subject stay inSyncCardsbut are not returned. The admin brings a subject back withundelete, while the adminDELETEstill 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.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.
Маршруты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Разбор и следующий урок с заготовкой — на диаграмме конвейера урока.
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 · recordssequenceDiagram
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Сценарий от начала до второго урока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"}'
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.Админка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 inlib/(запросы, токен, маршруты, узлы, диалог подтверждения, форматы) и(requests, token, routes, nodes, the confirmation dialog, formats) andsections/(по модулю на раздел). Без сборки, npm, фреймворков и внешних CDN, шрифты системные; раздаёт(one module per section). No build, npm, frameworks or external CDNs, system fonts; served byUseStaticFiles, выкатывается обычным and shipped with a plainfly deploy. - Маршруты в хэше.Hash routes.
#/pulse(стартовый),(the start),#/accounts,#/accounts/<owner>,#/subjects/<id>,#/subjects/<id>/lessons/<n>,#/talks/<id>,#/models,#/generations?role=…,#/release; сегменты кодируются; segments are encoded withencodeURIComponent, ссылку на любой экран можно сохранить. Сервер в маршрутах с владельцем заменяет, 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 inlocalStorage(ключ(keyteach-console-token) и уходит только заголовком) and goes only as theAuthorization: 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 (kindqa, issued in «Люди» with the «и админка на чтение (QA)» box). After sign-in the page asksGET /admin/me; приcanWrite: falseв шапке плашка «Только чтение», а кнопки и формы записи не рисуются. Это удобство, а не защита: на сервереCaller.CanReadAdmin(общий токен или QA) пускает только к GET-маршрутам once; withcanWrite: falsethe header shows a «Только чтение» badge and write buttons and forms are not drawn. That is convenience, not protection: on the serverCaller.CanReadAdmin(shared or QA) opens only the GET routes of/admin/*, все записи по-прежнему требуютIsAdmin(только общий токен) и отвечают QA 403. В остальном API токен QA — обычный тестировщикtester:<имя>., while every write still requiresIsAdmin(shared token only) and answers 403 to QA. Elsewhere in the API a QA token is a plain testertester:<name>. FR-75.7 - Безопасность страницы.Page security. ДляFor
/consolemiddleware доa middleware beforeUseStaticFilesставитsetsContent-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none',Referrer-Policy: no-referrerиandCache-Control: no-cache(после выкладки старые модули не смешиваются с новыми). Узлы строятся только через(after a deploy old modules do not mix with new ones). Nodes are built only withcreateElement//createTextNode: пользовательский текст не становится разметкой, атрибуты: user text never becomes markup, theon*иandstyleзапрещены,attributes are refused, andhref— только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.
Форматы данных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 emptytokensу ввода принимает любой текст.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иandrecNote— запись об усвоенном: она становится карточкой повтора и уходит в разбор.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 }
Локальный запуск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.
Деплой и релиз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.
Тесты и наблюдение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.
Ссылки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.
server/Teach.Api/wwwroot/docs/ и выкатывается вместе с сервером. Секретов в ней нет; токен API передаётся отдельно.and ships together with the server. It contains no secrets; the API token is provided separately.