# Телеметрія проходжень — контракт для фронтенду

Мета: зібрати **мікроповедінку** під час тесту (затримки на питаннях, зміни
відповідей, вагання, залежність від пояснень, навігація), щоб точно бачити, які
теми і типи помилок болять конкретному учню — і давати таргетовану рекомендацію.

Зберігається **кожне проходження** (і залогінених, і гостей — анонімно).

---

## 1. Життєвий цикл

```
1. (опційно) POST /api2/attempts/start        -> { attemptId }        // або згенеруй uuid на клієнті
2.  POST /api2/attempts/{attemptId}/events     -> { stored }           // батчами під час тесту (кожні ~15–30с або по 20–50 подій)
3.  POST /api2/attempts { attemptId, ... , events?, questionStats? }   // сабміт: оцінка + фіналізація телеметрії
```

- `attemptId` — UUID v4. Генеруй на клієнті при старті спроби; передавай його
  в **усі** три виклики. Якщо не передати — сервер створить свій і поверне в
  відповіді сабміту (`attemptId`), але тоді проміжні події не прив'яжуться.
- Крок 2 не обов'язковий, але **рекомендований для довгих тестів** — інакше при
  закритті вкладки посеред тесту губиться поведінка. При сабміті можна дослати
  залишок подій у полі `events`.
- `POST /attempts/{id}/events` throttle: 600/хв. Дедуп за `(attemptId, seq)` —
  повторна відправка того самого батча безпечна.

---

## 2. Події (`events[]`)

Кожна подія:

```jsonc
{
  "type": "answer_change",     // з таксономії нижче
  "seq": 42,                    // монотонний лічильник у межах спроби (0,1,2,…)
  "atMs": 83120,                // мс від attempt_start
  "questionOrder": 6,           // 0-based inOrder питання (для question-level подій)
  "questionType": "single",     // опційно
  "dwellMs": 12400,             // для question_exit — час на питанні цього візиту
  "fromValue": "2",             // для answer_change
  "toValue": "0",
  "payload": { }                // довільний JSON для деяких типів (див. нижче)
}
```

### Таксономія `type`

**Рівень спроби**
| type | коли | payload |
|---|---|---|
| `attempt_start` | тест відкрито | `{ questionCount }` |
| `attempt_resume` / `attempt_pause` | повернення/вихід із фокусу | |
| `tab_hidden` / `tab_visible` | `visibilitychange` | |
| `connectivity_lost` / `connectivity_restored` | offline/online | |
| `timer_warning` | лишилось мало часу | `{ secondsLeft }` |
| `attempt_submit` | натиснув «Завершити» | |
| `attempt_abandon` | пішов не завершивши | |

**Навігація**
| type | коли | payload |
|---|---|---|
| `question_enter` | питання стало активним | |
| `question_exit` | пішов з питання | `dwellMs` обов'язково |
| `navigate` | перехід між питаннями | `{ from, to, method: "swipe"\|"click"\|"jump" }` |

**Взаємодія**
| type | коли |
|---|---|
| `answer_select` | перший вибір відповіді на питанні (`toValue`) |
| `answer_change` | зміна вже даної відповіді (`fromValue` → `toValue`) |
| `answer_clear` | очистив відповідь |
| `answer_reorder` | connect: змінив порядок; `payload: { order: [...] }` |
| `explanation_open` / `explanation_close` | відкрив/закрив пояснення |
| `hint_open` | відкрив підказку |
| `flag_add` / `flag_remove` | позначив/зняв питання «на потім» |
| `image_zoom` | збільшив зображення питання |
| `scroll_deep` | доскролив умову до кінця |

Невідомі `type` тихо ігноруються сервером.

---

## 3. Rollup від клієнта (`questionStats[]`) — рекомендовано

Клієнт точніше за сервер знає фокус/таймінги. Передавай при сабміті масив
(по одному об'єкту на **побачене** питання):

```jsonc
{
  "order": 6,                          // 0-based inOrder
  "dwellMs": 41000,                    // сумарний час на питанні
  "visits": 2,                         // скільки разів заходив
  "answerChanges": 1,
  "firstAnswerMs": 12000,              // мс від входу в питання до першої відповіді
  "lastChangeMs": 38000,
  "changedFromCorrect": true,          // мав правильну -> змінив на неправильну
  "changedToCorrect": false,
  "explanationOpened": true,
  "explanationOpenedBeforeAnswer": true,
  "hintUsed": false,
  "flagged": false,
  "confident": true                    // (опційно) учень позначив впевненість / визначено клієнтом
}
```

Якщо `questionStats` не передати — сервер відновить показники з `events` (гірша
точність). Правильність/бали завжди рахує сервер, клієнтські значення не довіряються.

`clientMeta` (опційно, у `start` і `store`):
`{ platform: "web"|"ios"|"android", appVersion, connection: "wifi"|"cellular"|"offline" }`.

---

## 4. Що з цього виходить (аналітика)

### `GET /api2/me/insights?subject=`
```jsonc
{
  "headline": "Найбільше очок можна повернути тут: Квадратні рівняння (засвоєння 41%).",
  "painPoints": [{
    "skillKey": "kvadratni-rivniannia", "label": "...", "subject": "math",
    "mastery": 0.41, "trend": -0.06, "attempts": 9, "accuracy": 0.44,
    "behaviourFlag": "rushing" | "second_guessing" | "overthinking" | "knowledge_gap",
    "dominantError": "wrong_option" | "near_miss_numeric" | "partial_multiple" | "blank" | "reversed_connect",
    "impact": 0.83
  }],
  "errorPatterns": {
    "byKind": { "wrong_option": 12, "near_miss_numeric": 4, ... },
    "recurringDistractors": [{ "questionLabel": "Математика · Q7", "youPicked": "1", "correct": "3", "times": 4 }]
  },
  "paceProfile": [{ "subject": "math", "avgSeconds": 22.4, "vsPeers": 0.6, "tendency": "rushes", ... }],
  "revisionDue": [{ "label": "...", "daysAgo": 34, "mastery": 0.78 }],
  "behaviour": { "dominantArchetype": "rusher", "avgGuessRate": 0.31, "avgPaceVsPeers": 0.7, "confidenceCalibration": 0.55 },
  "studyPlan": [{
    "priority": 1, "label": "...", "subject": "math",
    "reason": "Ти відповідаєш надто швидко — 44% правильних...",
    "action": "timed_slowdown" | "first_answer_lock" | "theory_then_drill",
    "theory": [{ "slug": "...", "title": "...", "difficulty": 2 }],
    "tests": [{ "slug": "...", "title": "...", "type": "topic" }]
  }]
}
```

### `GET /api2/me/mastery?subject=&dimension=topic|position|type`
Сирий rolling-модель засвоєння (EWMA точності) по вимірах: тема / позиція
питання в предметі / тип питання. Поля: `mastery` (0..1), `trend`, `accuracy`,
`avgSeconds`, `rushRate`, `secondGuessRate`, `lastCorrectAt`.

Для `dimension=topic`: `skillKey` — це **id теми** (число як рядок, з дерева
тем `GET subjects/{subject}/topics`), не слаг і не назва — непрозорий
ідентифікатор для round-trip'у (напр. в `PUT /me/focus`). Показуйте
користувачу поле `label`, а не `skillKey`.

Раніше `topic`-сигнал з'являвся лише з окремих однотемних тестів (`type:
"topic"`), тож у більшості акаунтів `dimension=topic` був майже завжди
порожній. Тепер тема визначається **на кожне питання окремо**, тож і НМТ/ЗНО-
проходження (де питання з різних тем в одному тесті) теж генерують сигнал —
очікуйте суттєво більше даних тут, ніж раніше.

### Порада наприкінці тесту — у відповіді `POST /api2/attempts`
```jsonc
"advice": { "tone": "praise" | "nudge" | "neutral", "message": "…", "focus"?: {...} }
```
Одне речення, ненав'язливо. Якщо людина десь виправилась — хвалить конкретно
(«{тема} — раніше буксувало, а зараз краще»). Інакше м'яко підсвічує патерн
(поспіх / друга думка / слабка тема). Якщо сказати нема чого — `advice: null`.
Не повторюється двічі поспіль дослівно. Також доступна в `attempts.adviceMessage`
та в `/me/focus.recentAdvice`.

### `GET /api2/me/focus` — дошка «над чим працювати» (кабінет)
```jsonc
{
  "workOn": [{ "dimension": "topic", "skillKey": "…", "label": "…", "subject": "math",
               "pinned": false, "mastery": 0.41, "trend": -0.05, "accuracy": 0.4,
               "behaviourFlag": "rushing" }],
  "subjects": [{ "subject": "math", "accuracy": 0.52, "rushRate": 0.3, "status": "needs_work" | "developing" | "solid" }],
  "improving": [{ "label": "…", "subject": "biology", "mastery": 0.66, "gain": 0.15 }],
  "recentAdvice": [{ "message": "…", "tone": "praise", "subject": "math", "at": 1730000000 }]
}
```
`workOn` авто-наповнюється зі слабких навичок після кожного тесту.

### `PUT /api2/me/focus` — керування дошкою
`{ dimension, skillKey, status: "pinned" | "dismissed" | "auto", note? }` → `204`.
`pinned` — підіймає нагору і посилює в інсайтах; `dismissed` — прибирає з
`workOn` і `painPoints`; `auto` — повертає в звичайний режим.

### `GET /api2/me/misconceptions?subject=` — чому обрано саме цей дистрактор
```jsonc
{
  "items": [{
    "questionLabel": "Математика · Q7", "questionType": "single",
    "youPicked": "1", "correct": "3", "times": 4, "avgSeconds": 6.2,
    "cohortShare": 0.58,                     // стільки ж % однокласників обрали те саме
    "pattern": "common_trap" | "adjacent_slip" | "far_miss" | "sign_or_scale_error"
               | "converse_confusion" | "overselection" | "underselection",
    "interpretation": "У Математика Q7 ти щоразу обираєш «1» — це найпопулярніша хибна відповідь…",
    "theory": [{ "slug": "…", "title": "…" }]
  }],
  "summary": { "common_trap": 3, "adjacent_slip": 2 },
  "stableMisconceptions": [ /* interpretation для times >= 3 */ ]
}
```
Логіка патерну: `adjacent_slip` (варіант поряд із правильним — неуважність),
`far_miss` (далеко — прогалина в темі), `common_trap` (обрав ту саму пастку, що
й більшість — закладена в завдання хиба), `sign_or_scale_error` (правильно за
модулем, хибний знак/порядок), `converse_confusion` (переплутав напрямок
відповідності), `over/underselection` (забагато/замало варіантів у multiple).

### `GET /api2/homeworks/{code}/results` (вчитель) — індивідуальна статистика + прапорці
Кожен рядок submission тепер має (коли є телеметрія): `attemptId`, `archetype`,
`avgSecondsPerQuestion`, `answerChanges`, `guessRate`, `errorSignature`,
`integrityRisk: "low" | "medium" | "high"`.

### `GET /api2/homeworks/{code}/integrity` (вчитель) — хто радив/обманював
```jsonc
{
  "overview": { "submissions": 24, "likelyGuessing": 3, "suspicious": 7,
                "archetypes": { "rusher": 8, "steady": 12, ... } },
  "submissions": [{
    "attemptId": "…", "name": "…", "score": 178, "elapsedSeconds": 210,
    "integrityScore": 38,               // 0..100, менше = підозріліше
    "risk": "high" | "medium" | "low",
    "signals": ["rapid_fire", "uniform_answers", "above_own_baseline"]
  }]                                     // відсортовано за зростанням integrityScore
}
```
Сигнали: `rapid_fire` (медіана <3с/питання), `no_interaction`, `uniform_answers`
(та сама літера ≥80%), `pattern_answers` (цикл ABCDABCD), `score_time_mismatch`,
`flawless_speed`, `above_own_baseline` (бал набагато вищий за власну історію в
предметі — крос-перевірка по всіх минулих тестах), `tab_switching` (часті
`tab_hidden` під час тесту — можлива стороння допомога), `high_guess_rate`.

### `GET /api2/homeworks/{code}/submissions/{attemptId}/integrity` (вчитель) — розбір однієї спроби
Той самий `integrityScore` / `signals` + `perQuestion` (dwell, зміни, вибір по
кожному питанню) + `baseline` (середній бал учня в цьому предметі, к-сть спроб).

> Усе це — **евристики для перегляду вчителем**, а не автоматичне покарання.

### `GET /api2/homeworks/{code}/behavior` (вчитель)
```jsonc
{
  "questions": [{
    "questionOrder": 6, "attempts": 24, "pctCorrect": 0.42,
    "avgSeconds": 38.1, "answerChangeRate": 0.8, "explanationOpenRate": 0.3,
    "rushed": 5, "struggled": 4,
    "topDistractors": [{ "answer": "1", "count": 9 }],
    "hesitationScore": 0.71
  }],
  "archetypes": { "rusher": 8, "steady": 12, "second_guesser": 4 },
  "hotspots": [ /* топ-5 питань за hesitationScore */ ]
}
```

---

## 5. Похідні сигнали (рахує сервер)

| сигнал | правило | навіщо |
|---|---|---|
| `rush` | відповів <5с і неправильно | «летить», не читає умову |
| `struggle` | >120с і неправильно | немає впевненого знання теми |
| guess | відповів <3с без взаємодій | вгадування |
| `changedFromCorrect` | мав правильну → змінив на хибну | треба «довіряй першій відповіді» |
| `archetype` спроби | `rusher` / `overthinker` / `second_guesser` / `explanation_leaner` / `quitter` / `steady` | загальний патерн |
| `paceIndex` | медіанна затримка спроби / когорта по предмету | швидше/повільніше за однолітків |
| `confidenceCalibration` | частка «впевнених» відповідей що були правильні | небезпечні прогалини (впевнений + хибний) |

---

## 6. Приватність

- Гостям не пишемо жодного PII, лише поведінку під анонімним `attemptId`.
- Не зберігаємо IP / fingerprint у телеметрії (тільки `platform` / `appVersion` /
  `connection` з `clientMeta`).
- `attemptId` не можна «перехопити»: якщо він уже належить іншому userId — сервер
  видасть новий.
- Показувати учню його ж `insights` — ок; агрегати класу — лише автору ДЗ /
  організації / адміну (та сама авторизація, що й `homeworks/{code}/results`).
