Skip to content

ТехПаспорт — Справочник API

GitHub: TechCon-ML-Team/TechCon_Passports Версия: 1.0.0

Адреса

Среда URL
Production https://passports.techcon-ml.ru
Dev stand https://passports.dev.techcon-ml.ru
Локально http://localhost:8000

Интерактивная документация

Swagger UI (OpenAPI 3.1): https://passports.techcon-ml.ru/docs ReDoc: https://passports.techcon-ml.ru/redoc OpenAPI JSON (импорт в Postman/Insomnia): https://passports.techcon-ml.ru/openapi.json


Аутентификация

Два типа токенов, оба передаются в заголовке Authorization: Bearer <токен>:

Тип Получение Срок жизни
Статический Bearer Предоставляется администратором Бессрочно
JWT POST /auth/token 24 ч (настраивается через JWT_EXPIRE_HOURS)

Матрица защиты

Эндпоинт Метод Токен обязателен
/auth/token POST Нет
/health GET Нет
/events/tasks GET Нет (только cookie сессии)
/upload POST Да
/health/deep GET Да
/api/* GET/POST/DELETE Да
/export/* GET Да
/admin/* POST Да
/task/{id}/update POST Да
/task/{id}/rename POST Да
/task/{id} DELETE Да

Сервис устанавливает cookie bti_session_id (httponly, samesite=lax, 1 год) при первом GET /. API-клиентам нужно передавать этот cookie, чтобы GET /api/tasks и GET /export/my возвращали задачи только их сессии. Если cookie нет при вызове /upload — сервер генерирует новый UUID сессии на лету (задачи будут созданы, но недоступны через /api/tasks без cookie).


Статусы задачи

Статус Описание
PENDING В очереди, ожидает свободного воркера
PROCESSING Запрос отправлен в LLM, ждём ответ
SUCCESS Данные успешно извлечены
FAILED Не удалось после ретраев (можно повторить через /api/task/{id}/retry)
ERROR Непредвиденная системная ошибка (можно повторить через /api/task/{id}/retry)
PERMANENT_ERROR Превышен лимит ретраев (3 попытки). Повтор невозможен

Формат ответов

Успех

Все JSON-эндпоинты возвращают ответ в обёртке:

{"ok": true, "data": { ... }}

Ошибка

{"ok": false, "error": {"code": "UPPER_SNAKE_CODE", "message": "Описание"}}

Коды ошибок

Код HTTP Описание
UNAUTHORIZED 401 Отсутствует или невалидный Bearer-токен
TOKEN_EXPIRED 401 JWT истёк
INVALID_API_KEY 401 Неверный API-ключ в /auth/token
TASK_NOT_FOUND 404 Задача не найдена
NOT_FOUND 404 Ресурс не найден (изображение и т.д.)
VALIDATION_ERROR 400 Невалидные входные данные
INVALID_TASK_STATUS 400 Операция невозможна в текущем статусе
NO_SESSION 400 Отсутствует cookie bti_session_id
RETRY_CONFLICT 409 Задача не в статусе ERROR/FAILED
STORAGE_UNAVAILABLE 503 Хранилище недоступно
INTERNAL_ERROR 500 Внутренняя ошибка
DATABASE_UNAVAILABLE 500 БД недоступна
LLM_UNAVAILABLE 503 Провайдер LLM недоступен

Исключения из обёртки

Не оборачиваются: бинарные файлы (Excel, PDF), HTML-страницы, SSE-поток, 204 No Content.


1. Аутентификация

POST /auth/token

Выдаёт краткосрочный JWT по статическому API-ключу.

Аутентификация: нет

Тело запроса (application/json):

Поле Тип Обязательно Описание
api_key string да Статический токен, предоставленный администратором

Ответ 200:

{
  "ok": true,
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "expires_in": 86400
  }
}
Поле Тип Описание
data.token string JWT для заголовка Authorization: Bearer <token>
data.expires_in int Срок жизни токена в секундах

Коды ошибок:

Код HTTP Условие
INVALID_API_KEY 401 Неверный API-ключ

Типовой сценарий интеграции:

TOKEN=$(curl -s -X POST "$BASE/auth/token" \
  -H "Content-Type: application/json" \
  -d '{"api_key": "my-secret"}' | jq -r .data.token)

2. Загрузка файлов

POST /upload

Загружает один или несколько PDF-файлов для извлечения данных. При повторной загрузке идентичного файла (SHA-256 совпадение) сразу возвращает status: SUCCESS из кэша без обращения к LLM.

Аутентификация: да

Тело запроса (multipart/form-data):

Поле Тип Обязательно Описание
files file[] да PDF-файлы. Максимум 20 файлов, до 50 МБ каждый. Файлы больше 5 файлов разбиваются на батчи с задержкой 10 с

Ответ 200 (только при заголовке Accept: application/json):

{
  "ok": true,
  "data": {
    "tasks": [
      {"id": "3f1a2b4c-1234-5678-abcd-ef1234567890", "status": "PENDING"},
      {"id": "7e9d0c1a-5678-abcd-1234-ef1234567890", "status": "SUCCESS"}
    ]
  }
}
Поле Тип Описание
data.tasks[].id string UUID задачи для polling
data.tasks[].status string PENDING — новый файл, SUCCESS — из кэша (результат доступен немедленно)

Заголовки ответа: X-Poll-Interval: 10, Retry-After: 10 (если есть PENDING задачи).

Без заголовка Accept: application/json → редирект 303 на / для браузеров.

Коды ошибок:

Код Условие
400 Более 20 файлов за один запрос
401 Токен отсутствует или недействителен

Типовой сценарий интеграции:

TASK_ID=$(curl -s -X POST "$BASE/upload" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" \
  -F "files=@passport.pdf" | jq -r '.data.tasks[0].id')

POST /api/v1/upload-images

Загружает изображения для последующей сборки в PDF. Возвращает список с превью (base64 data URL) для drag-and-drop упорядочивания перед сборкой.

Аутентификация: да

Тело запроса (multipart/form-data):

Поле Тип Обязательно Описание
files file[] да Изображения: jpg, jpeg, png, tiff, bmp, webp. Максимум 20 файлов, до 50 МБ

Ответ 200:

{
  "ok": true,
  "data": {
    "images": [
      {
        "id": "3f1a2b4c-1234-5678-abcd-ef1234567890",
        "filename": "scan_page1.png",
        "thumb_url": "data:image/jpeg;base64,/9j/4AAQ...",
        "order": 0
      }
    ]
  }
}
Поле Тип Описание
data.images[].id string UUID изображения во временном хранилище (привязан к cookie сессии)
data.images[].filename string Оригинальное имя файла
data.images[].thumb_url string Data URL превью (≈10–30 КБ) для отображения в UI
data.images[].order int Начальный порядок (по имени файла, 0-based)

Коды ошибок:

Код Условие
400 Ни один файл не является поддерживаемым изображением, или превышен лимит
401 Токен отсутствует или недействителен

Типовой сценарий интеграции:

curl -X POST "$BASE/api/v1/upload-images" \
  -H "Authorization: Bearer $TOKEN" \
  -b "bti_session_id=<session>" \
  -F "files=@page1.jpg" -F "files=@page2.png"

POST /api/v1/create-pdf

Собирает PDF из ранее загруженных изображений (из /api/v1/upload-images) и запускает задачу на извлечение данных.

Аутентификация: да

Тело запроса (application/json):

Поле Тип Обязательно Описание
image_ids string[] да Упорядоченный список UUID изображений из /api/v1/upload-images. Порядок определяет последовательность страниц в PDF

Ответ 200:

{
  "ok": true,
  "data": {
    "task_id": "3f1a2b4c-1234-5678-abcd-ef1234567890",
    "status": "PENDING"
  }
}
Поле Тип Описание
data.task_id string UUID задачи для polling через /api/task/{id}/status
data.status string PENDING — задача поставлена в очередь, SUCCESS — результат из кэша

Заголовки ответа: X-Poll-Interval: 10, Retry-After: 10 (если status: PENDING).

Коды ошибок:

Код Условие
400 Пустой список image_ids или неверный UUID изображения
401 Токен отсутствует или недействителен
503 Хранилище PDF недоступно (дисковая ошибка)

Типовой сценарий интеграции:

curl -X POST "$BASE/api/v1/create-pdf" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -b "bti_session_id=<session>" \
  -d '{"image_ids": ["3f1a2b4c-...", "7e9d0c1a-..."]}'

POST /api/v1/rotate-image/{image_id}

Поворачивает временно хранящееся изображение на 90°. Требует тот же bti_session_id, что использовался при загрузке.

Аутентификация: да

Параметры пути:

Параметр Тип Описание
image_id string UUID изображения из /api/v1/upload-images

Параметры запроса:

Параметр Тип Обязательно Описание
direction string да cw — по часовой, ccw — против часовой

Ответ 200:

{
  "ok": true,
  "data": {
    "image_id": "3f1a2b4c-1234-5678-abcd-ef1234567890",
    "thumb_url": "data:image/jpeg;base64,/9j/4AAQ..."
  }
}

Коды ошибок:

Код Условие
400 Нет активной сессии или неверный direction
401 Токен отсутствует или недействителен
404 Изображение не найдено в сессии

Типовой сценарий интеграции:

curl -X POST "$BASE/api/v1/rotate-image/$IMAGE_ID?direction=cw" \
  -H "Authorization: Bearer $TOKEN" \
  -b "bti_session_id=<session>"

DELETE /api/v1/image/{image_id}

Удаляет временно хранящееся изображение из сессии.

Аутентификация: да

Параметры пути:

Параметр Тип Описание
image_id string UUID изображения

Ответ 204: пустое тело.

Коды ошибок:

Код Условие
400 Нет активной сессии
401 Токен отсутствует или недействителен
404 Изображение не найдено в сессии

Типовой сценарий интеграции:

curl -X DELETE "$BASE/api/v1/image/$IMAGE_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -b "bti_session_id=<session>"

3. Задачи

GET /api/tasks

Возвращает задачи, привязанные к сессии из cookie bti_session_id. Сортировка по убыванию даты создания.

Аутентификация: да

Параметры запроса:

Параметр Тип По умолчанию Описание
page int 1 Номер страницы (начиная с 1)
limit int 50 Записей на страницу (макс. 200)
status string Фильтр: PENDING, PROCESSING, SUCCESS, FAILED, ERROR

Ответ 200:

{
  "ok": true,
  "data": {
    "items": [
      {
        "id": "3f1a2b4c-1234-5678-abcd-ef1234567890",
        "filename": "passport_42.pdf",
        "status": "SUCCESS",
        "cached": false,
        "created_at": "2026-03-05T12:34:56+00:00"
      }
    ],
    "total": 1,
    "page": 1,
    "limit": 50
  }
}

Коды ошибок:

Код Условие
401 Токен отсутствует или недействителен

Типовой сценарий интеграции:

curl -s "$BASE/api/tasks?status=SUCCESS&limit=100" \
  -H "Authorization: Bearer $TOKEN" \
  -b "bti_session_id=<session>" | jq '.data.items[].id'

GET /api/task/{task_id}/status

Быстрая проверка статуса задачи. Использовать для polling.

Аутентификация: да

Параметры пути:

Параметр Тип Описание
task_id string UUID задачи

Ответ 200:

{"ok": true, "data": {"status": "SUCCESS"}}

Возможные значения data.status: PENDING, PROCESSING, SUCCESS, FAILED, ERROR, PERMANENT_ERROR.

Заголовок X-Poll-Interval: 10 при статусах PENDING / PROCESSING.

Коды ошибок:

Код HTTP Условие
TASK_NOT_FOUND 404 Задача не найдена

Типовой сценарий интеграции:

import httpx, time

with httpx.Client(base_url=BASE, headers=auth) as client:
    while True:
        r = client.get(f"/api/task/{task_id}/status")
        status = r.json()["data"]["status"]
        if status in ("SUCCESS", "FAILED", "ERROR", "PERMANENT_ERROR"):
            break
        time.sleep(10)

GET /api/task/{task_id}/result

Полный результат извлечения данных из паспорта.

Аутентификация: да

Параметры пути:

Параметр Тип Описание
task_id string UUID задачи

Ответ 200 (задача завершена):

{
  "ok": true,
  "data": {
    "id": "3f1a2b4c-1234-5678-abcd-ef1234567890",
    "filename": "passport_42.pdf",
    "status": "SUCCESS",
    "cached": false,
    "created_at": "2026-03-05T12:34:56+00:00",
    "error_message": null,
    "data": {
    "reasoning": "Внутренняя цепочка рассуждений LLM (для отладки качества)",
    "object_info": {
      "classification": "Жилое",
      "commissioning_year": 1985,
      "floors_count": 9,
      "plan_shape": "Прямоугольная",
      "project_year": 1970,
      "owner_name": "МКД",
      "address_extra": "-",
      "responsibility_level": "Нормальный",
      "basement_status": "Есть",
      "constructive_type": "Бескаркасный",
      "entrances_count": 4,
      "project_type": "Типовой",
      "owner_address": null
    },
    "object_specs": {
      "building_area": 607.32,
      "usable_area": 2890.40,
      "walls": "Стены кирпичные",
      "height": 15.30,
      "volume": 9292.00,
      "reconstructions": "нет данных",
      "lintels": "Нет",
      "columns": "Нет",
      "loggias": "нет",
      "balconies": "Нет",
      "height_config": "Постоянная",
      "load_bearing_system": "Фундамент, стены, перекрытия и покрытия"
    }
  }
  }
}

Ответ 200 (задача в процессе):

{"ok": true, "data": {"id": "3f1a2b4c-...", "status": "PROCESSING", "data": null}}

Заголовок: X-Poll-Interval: 10.

null в полях data — поле отсутствует в документе или не распознано LLM. Числа округлены до 2 знаков.

Коды ошибок:

Код Условие
404 Задача не найдена
503 Задача завершилась с ошибкой провайдера LLM (PROVIDER_RATE_LIMIT / PROVIDER_SERVER_ERROR)

Типовой сценарий интеграции:

curl -s "$BASE/api/task/$TASK_ID/result" \
  -H "Authorization: Bearer $TOKEN" | jq '.data.data.object_info'

4. Редактирование задач

POST /task/{task_id}/update

Вручную обновляет поля извлечённого результата. Только для задач со статусом SUCCESS.

Аутентификация: да

Параметры пути:

Параметр Тип Описание
task_id string UUID задачи

Тело запроса (application/x-www-form-urlencoded):

Ключи имеют формат <секция>:<поле>. Допустимые секции: object_info, object_specs. Пустое значение сбрасывает поле в null. Числа принимаются как с точкой, так и с запятой.

Ответ 200 (при заголовке Accept: application/json):

{"ok": true, "data": {"id": "3f1a2b4c-1234-5678-abcd-ef1234567890"}}

Без заголовка Accept: application/json → редирект 303 на /task/{id}?saved=1.

Коды ошибок:

Код Условие
400 Задача не в статусе SUCCESS
401 Токен отсутствует или недействителен
404 Задача не найдена

Типовой сценарий интеграции:

curl -X POST "$BASE/task/$TASK_ID/update" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" \
  --data-urlencode "object_info:commissioning_year=1985" \
  --data-urlencode "object_specs:building_area=607.32"

DELETE /task/{task_id}

Удаляет задачу и файл с диска.

Аутентификация: да

Параметры пути:

Параметр Тип Описание
task_id string UUID задачи

Ответ 200: пустое тело (text/html, HTMX-совместимость).

Коды ошибок:

Код Условие
401 Токен отсутствует или недействителен
404 Задача не найдена (возвращает application/json)

Типовой сценарий интеграции:

curl -X DELETE "$BASE/task/$TASK_ID" -H "Authorization: Bearer $TOKEN"

POST /task/{task_id}/rename

Переименовывает задачу. Всегда возвращает редирект 303 (нет JSON-режима).

Аутентификация: да

Параметры пути:

Параметр Тип Описание
task_id string UUID задачи

Тело запроса (application/x-www-form-urlencoded):

Поле Тип Обязательно Описание
name string да Новое имя задачи, до 255 символов

Ответ 303: редирект на /.

Коды ошибок:

Код Условие
400 Пустое имя или длиннее 255 символов
401 Токен отсутствует или недействителен
404 Задача не найдена

Типовой сценарий интеграции:

curl -X POST "$BASE/task/$TASK_ID/rename" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "name=Здание на ул. Ленина"

POST /api/task/{task_id}/retry

Повторно ставит в очередь задачу в статусе ERROR или FAILED.

Аутентификация: да

Параметры пути:

Параметр Тип Описание
task_id string UUID задачи

Ответ 200:

{"ok": true, "data": {"status": "PENDING"}}

Коды ошибок:

Код HTTP Условие
UNAUTHORIZED 401 Токен отсутствует или недействителен
TASK_NOT_FOUND 404 Задача не найдена
RETRY_CONFLICT 409 Задача не в статусе ERROR / FAILED

Типовой сценарий интеграции:

curl -X POST "$BASE/api/task/$TASK_ID/retry" -H "Authorization: Bearer $TOKEN"

5. Экспорт

GET /export/all

Все успешные задачи (статус SUCCESS) в виде сводной Excel-таблицы.

Аутентификация: да

Ответ 200: файл application/vnd.openxmlformats-officedocument.spreadsheetml.sheet Имя файла: batch_export_Обработанный_<дата>.xlsx

Коды ошибок:

Код Условие
401 Токен отсутствует или недействителен

Типовой сценарий интеграции:

curl "$BASE/export/all" -H "Authorization: Bearer $TOKEN" -o all_passports.xlsx

GET /export/my

Задачи текущей сессии (статус SUCCESS) в Excel. Зависит от cookie bti_session_id.

Аутентификация: да

Ответ 200: файл Excel. Если cookie отсутствует — возвращает пустой файл без ошибки.

Коды ошибок:

Код Условие
401 Токен отсутствует или недействителен

Типовой сценарий интеграции:

curl "$BASE/export/my" \
  -H "Authorization: Bearer $TOKEN" \
  -b "bti_session_id=<session>" \
  -o my_passports.xlsx

GET /export/{task_id}/excel

Одна задача в вертикальном Excel-формате (поле → значение).

Аутентификация: да

Параметры пути:

Параметр Тип Описание
task_id string UUID задачи

Ответ 200: файл Excel. Имя файла: <имя_задачи>_Обработанный_<дата>.xlsx

Коды ошибок:

Код Условие
401 Токен отсутствует или недействителен
404 Задача не найдена

Типовой сценарий интеграции:

curl "$BASE/export/$TASK_ID/excel" -H "Authorization: Bearer $TOKEN" -o passport.xlsx

GET /export/{task_id}/pdf

Одна задача в виде печатного PDF-отчёта.

Аутентификация: да

Параметры пути:

Параметр Тип Описание
task_id string UUID задачи

Ответ 200: файл PDF. Имя файла: <имя_задачи>_Обработанный_<дата>.pdf

Коды ошибок:

Код Условие
401 Токен отсутствует или недействителен
404 Задача не найдена

Типовой сценарий интеграции:

curl "$BASE/export/$TASK_ID/pdf" -H "Authorization: Bearer $TOKEN" -o report.pdf

6. Мониторинг

GET /events/tasks

Server-Sent Events поток статусов задач сессии. Не требует Bearer-токена (браузерный EventSource не может слать заголовки). Поток закрывается через 10 минут или когда нет активных задач.

Аутентификация: нет (определяется по cookie bti_session_id)

Ответ 200 (text/event-stream):

data: {"id": "3f1a2b4c-...", "status": "PROCESSING"}

data: {"id": "3f1a2b4c-...", "status": "SUCCESS"}

: keepalive

event: done
data: {}
Тип события Когда
data: {...} Статус задачи изменился
: keepalive Каждые 2 с без изменений
event: done Нет активных задач или сессия пуста

Коды ошибок:

Код Условие
400 Cookie bti_session_id отсутствует

Типовой сценарий интеграции:

const es = new EventSource('/events/tasks');
es.onmessage = (e) => console.log(JSON.parse(e.data));
es.addEventListener('done', () => es.close());

GET /health

Проверяет доступность БД и наличие активных воркеров Celery. Не требует авторизации.

Аутентификация: нет

Ответ 200 (сервис работает нормально):

{
  "ok": true,
  "data": {
    "database": "ok",
    "workers_online": 1,
    "version": "1.0.0"
  }
}

Ответ 503 / 500 (деградация):

{
  "ok": false,
  "error": {
    "code": "WORKERS_UNAVAILABLE",
    "message": "No Celery workers online"
  }
}
Код ошибки HTTP Условие
WORKERS_UNAVAILABLE 503 workers_online = 0
DATABASE_UNAVAILABLE 500 БД не отвечает

workers_online: 0 при ответе 200 означает graceful degradation: сервис принимает загрузки, но обработка в очереди. Gatus мониторинг: ok == true.

Типовой сценарий интеграции:

curl -s https://passports.techcon-ml.ru/health | jq .data.workers_online

GET /health/deep

Расширенная проверка: БД + воркеры + провайдер LLM. Требует авторизации.

Аутентификация: да

Ответ 200:

{
  "ok": true,
  "data": {
    "database": "ok",
    "workers_online": 1,
    "version": "1.0.0",
    "llm_status": "ok",
    "llm_provider": "openrouter",
    "llm_error": null
  }
}

llm_status: "ok" | "unconfigured" | "error". "unconfigured" — нет OPENROUTER_API_KEY. Для Gatus: condition != error.

Коды ошибок:

Код Условие
503 LLM-провайдер недоступен (llm_status: error)
500 БД недоступна

Типовой сценарий интеграции:

curl -s "$BASE/health/deep" -H "Authorization: Bearer $TOKEN" | jq .data.llm_status

7. Администрирование

POST /admin/cleanup

Удаляет задачи старше указанного числа дней. Рекомендуется запускать по расписанию (cron).

Аутентификация: да

Параметры запроса:

Параметр Тип По умолчанию Описание
days int 30 Удалить задачи старше N дней (1–365)

Ответ 200:

{"ok": true, "data": {"deleted_tasks": 12}}

Коды ошибок:

Код Условие
401 Токен отсутствует или недействителен
422 days вне диапазона 1–365

Типовой сценарий интеграции:

curl -X POST "$BASE/admin/cleanup?days=90" -H "Authorization: Bearer $TOKEN"

GET /api/admin/failed-stats

Статистика упавших задач за последний час.

Аутентификация: да

Ответ 200:

{
  "ok": true,
  "data": {
    "failed_last_hour": 3,
    "checked_at": "2026-04-06T10:00:00+00:00"
  }
}

Учитывает статусы: ERROR, FAILED, PERMANENT_ERROR.

Коды ошибок:

Код Условие
401 Токен отсутствует или недействителен

Типовой сценарий интеграции:

curl -s "$BASE/api/admin/failed-stats" -H "Authorization: Bearer $TOKEN"

Сценарий полной интеграции (end-to-end)

curl

BASE="https://passports.techcon-ml.ru"

# 1. Получить JWT
TOKEN=$(curl -s -X POST "$BASE/auth/token" \
  -H "Content-Type: application/json" \
  -d '{"api_key": "my-secret"}' | jq -r .data.token)

# 2. Загрузить файл
TASK_ID=$(curl -s -X POST "$BASE/upload" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" \
  -F "files=@passport.pdf" | jq -r '.data.tasks[0].id')

# 3. Polling статуса
while true; do
  STATUS=$(curl -s "$BASE/api/task/$TASK_ID/status" \
    -H "Authorization: Bearer $TOKEN" | jq -r .data.status)
  [ "$STATUS" = "SUCCESS" ] && break
  [ "$STATUS" = "PERMANENT_ERROR" ] && echo "Ошибка, повтор невозможен" && exit 1
  [ "$STATUS" = "FAILED" ] && curl -s -X POST "$BASE/api/task/$TASK_ID/retry" \
    -H "Authorization: Bearer $TOKEN"
  sleep 10
done

# 4. Получить данные
curl -s "$BASE/api/task/$TASK_ID/result" \
  -H "Authorization: Bearer $TOKEN" | jq .data.data

# 5. Скачать Excel
curl "$BASE/export/$TASK_ID/excel" \
  -H "Authorization: Bearer $TOKEN" \
  -o "passport_$TASK_ID.xlsx"

Python (httpx)

import httpx
import time

BASE = "https://passports.techcon-ml.ru"

with httpx.Client(base_url=BASE, timeout=30) as client:
    # 1. Получить JWT
    resp = client.post("/auth/token", json={"api_key": "my-secret"})
    token = resp.json()["data"]["token"]
    headers = {"Authorization": f"Bearer {token}"}

    # 2. Загрузить PDF
    with open("passport.pdf", "rb") as f:
        resp = client.post(
            "/upload",
            files={"files": ("passport.pdf", f, "application/pdf")},
            headers={**headers, "Accept": "application/json"},
        )
    task_id = resp.json()["data"]["tasks"][0]["id"]

    # 3. Polling
    while True:
        resp = client.get(f"/api/task/{task_id}/status", headers=headers)
        status = resp.json()["data"]["status"]
        if status in ("SUCCESS", "FAILED", "ERROR", "PERMANENT_ERROR"):
            break
        time.sleep(10)

    # 4. Результат
    resp = client.get(f"/api/task/{task_id}/result", headers=headers)
    result = resp.json()["data"]
    print(f"Status: {result['status']}")
    if result["data"]:
        print(f"Object: {result['data']['object_info']}")
        print(f"Specs:  {result['data']['object_specs']}")