# Ход: одна просьба клиента

Ход — это единица работы сервиса. Внутри него ассистент читает разговор,
пишет изменение заказа, собирает его геометрией и проверяет; не сошлось —
переспрашивает себя до трёх раз. Поэтому ход идёт **десятки секунд, иногда
минуты**, и отвечает сервис заявкой, а не результатом.

```
POST /v1/conversations/{id}/turns  →  202 {turn:{id, state:"queued"}}
                                        │
                     queued → running → done | stopped | failed
                                        │
                        вебхук turn.completed / .stopped / .failed
```

## Один ход на разговор

Пока идёт ход, второй запрос по тому же разговору отвечает **409
`conflict_busy`**. Это не очередь и не защита от нагрузки: следующая просьба
человека почти всегда зависит от того, чем кончилась предыдущая, и собирать
их параллельно значило бы собирать по устаревшему заказу.

Если клиент дописал, пока ход идёт, — у вас два честных пути: дождаться исхода
и послать дописанное следующим ходом либо оборвать текущий
(`POST /v1/turns/{id}/stop`) и послать просьбу целиком заново. Остановленный
ход денег не стоит.

## Идемпотентность

Заголовок `Idempotency-Key` — ваш id сообщения:

```
Idempotency-Key: msg-991
```

Повтор с тем же ключом возвращает **тот же** ход. Без заголовка повтор —
это второй ход и вторые деньги. Ключ уникален в пределах разговора.

## Исход

```json
{
  "status": "built",
  "note": "Собрал кухню 3 метра: тумба под мойку 800, посудомойка 600, ящики. Сверху четыре навесных шкафа.",
  "applied": 9,
  "turn": 1,
  "order_id": "lead-7781",
  "findings": [
    { "code": "GEO-GAP", "severity": "warning", "path": "низ.шкаф2",
      "msg": "…", "fixHint": "…" }
  ],
  "why": "Ряд не сошёлся: мебели на 925 мм больше, чем стены…",
  "usage": { "model": "…", "attempts": 1, "prompt_tokens": 12400,
             "completion_tokens": 900, "cost_usd": 0.0231, "ms": 41200, "coins": 1 }
}
```

| Поле | Что это |
|---|---|
| `status` | Чем кончилось — таблица ниже |
| `note` | **Ответ клиенту**, по-русски. Единственное, что ему показывают |
| `applied` | Сколько узлов заказа изменилось. `0` — заказ не тронут |
| `turn` | Номер хода в разговоре |
| `findings` | Находки проверки: `code`, `severity` (`error`/`warning`), `path`, `msg`, `fixHint?`. Есть не всегда |
| `why` | Почему не вышло — по-русски, одной фразой. Только у неудачных ходов |
| `usage` | Чем обошёлся ход. `coins` — сколько монет он стоит |

### Статусы

| `status` | Что случилось | Что делать |
|---|---|---|
| `built` | Мебель собрана, заказ изменился | Показать `note` и обновить картинку |
| `parsed` | Приложенный чертёж прочитан вслух; мебель НЕ собрана | Показать `note` — это разбор. Клиент поправляет словами или говорит «собирай» |
| `question` | Ассистент ответил словами: спросил недостающее или отказался | Показать `note` и ждать ответа клиента. Картинка остаётся прежней |
| `rejected` | Просьба спорит с нормой производства; переспрашивать нечем | Показать `note` и `why`. Решение за человеком: другое число или другой состав |
| `failed` | Ход не состоялся: модель не поняла, оборвалась связь | Клиенту ничего не показывать. Предложить сказать иначе |

`question` — **нормальный** исход, а не ошибка. Ассистенту велено спрашивать
то, чего он не может знать: вдоль какой стены мебель, какой длины стена,
какая техника встраивается, что мешает в комнате. Спрашивает он по одному-два
пункта за ход, а не анкетой.

Наоборот, то, что решается правилами (наполнение тумбы, сторона открывания,
высота полки), он **не спрашивает**, а собирает по умолчанию и называет это
строкой в `note`.

**Картинку не отменяет ни один статус.** Вид принадлежит разговору, а не ходу:
пока в разговоре что-то собрано, `GET .../render` отдаёт его после ЛЮБОГО
хода. `built` значит «картинка изменилась, возьмите новую», а не «только
теперь она есть»: после `question` и `rejected` в силе прежняя, и ссылку
на неё можно выписать заново хоть тем же запросом.

## Картинки

К просьбе прикладывается до **двух** картинок, каждая не больше 5 МБ:

```json
{ "text": "собери по этому плану",
  "images": [{ "mime": "image/png", "b64": "iVBORw0…" }] }
```

Форматы: `png`, `jpeg`, `webp`, `gif`.

**Чертёж сперва читается вслух.** На картинку с составом (модули вдоль стены,
в порядке и с размерами) ассистент отвечает не мебелью, а разбором по-русски:
что стоит у какой стены, в каком порядке, на скольких миллиметрах, какие числа
он взял не с чертежа и чего не разобрал. Статус такого хода — `parsed`, заказ
при этом не меняется. Клиент правит разбор словами («вторая стена 3400»,
«бутылочницы нет») или говорит «собирай» — и следующий ход собирает всё разом.

Двух картинок хватает **по сроку хода**: чертёж разбирает медленная модель,
и ход с картинкой идёт три-пять минут.

**Мелкая картинка** (меньше 600 пикселей по короткой стороне) не отбивается,
но читается плохо: швы в пару пикселей превращают семь створок в пять, и шкаф
по такому разбору собирается без единой ошибки. Есть крупнее — шлите крупнее.

**Картинка помнится ровно один следующий ход.** Нужна снова — приложите снова.

## Деньги

| Что | Сколько |
|---|---|
| Просьба словами | 1 монета |
| Просьба с картинкой | 8 монет |
| `question`, `rejected`, `failed`, остановленный ход | **0** |

Списывается ход, который дал работу: `built` и `parsed`.

**Единственное исключение** — ход с картинкой: он списывает свои восемь монет
всегда, когда ассистент ответил, даже если в заказе не изменилось ничего.
Работа там сделана разбором чертежа, и делает её самая дорогая модель сервиса.
Ход с картинкой, который не состоялся вовсе (модель не ответила, связь
оборвалась), не списывается и здесь.

Число картинок на цену не влияет: дорог разбор, а не изображение.

Не хватает монет — `402 quota_exceeded` ещё до вопроса модели. В `details`
лежит `reason`: `out_of_coins`, `period_over`, `canceled`, `no_subscription`.

Почём монета и как она покупается объёмом — в [pricing.md](pricing.md).

## Сроки

Ход живёт до **четырёх минут**; не уложился — `failed`. Разговор живёт
**30 дней** с последней просьбы и продлевается каждым ходом.

Если сервис перезапустили посреди хода, ход придёт `failed` вебхуком, а
разговор освободится — «вечно занятых» разговоров не бывает.
