API для интеграции: запись на входе, JSON и PDF на выходе

Отправьте ЭКГ по HTTPS — через несколько секунд заберите структурированное заключение и готовый PDF-отчёт на 9 страниц. Подходит для МИС, телемедицинских платформ и кардиографов: один запрос на запись, ответ — в формате, который можно сразу сохранить в карту пациента.

≈6сот загрузки записи до готового PDF
4формата записи: NPZ, CSV, EDF, PDF
100–1000Гцчастота дискретизации
2–60сдлительность записи

Как получить доступ

Тестовый токен выдаём по запросу: напишите в Telegram @silamisli, на почту info@kardiopilot.ru или оставьте заявку. Токен персональный: у него есть срок действия и суточный лимит заданий, его можно отозвать в любой момент.

Адрес и авторизация

Базовый адрес: https://kardiopilot.ru/api/v1
Заголовок: Authorization: Bearer kp_…
Токен не встраивают в браузерный JavaScript и не передают в адресной строке — только с вашего сервера. В клинике тот же API работает внутри сети, без интернета.

Методы

Все адреса — относительно https://kardiopilot.ru/api/v1. Каждый клиент видит только свои задания.

МетодАдресЧто делает
GET/meПроверка токена: имя клиента, срок действия, суточный лимит и сколько уже израсходовано.
POST/jobsОтправить запись. Обязательно только file. Частота и единицы CSV определяются по файлу, поля fs и units нужны лишь для исправления. Данные пациента по желанию: sex, age, height, weight, bp, name. interview=true — с опросом пациента. Ответ 202 с id.
POST/qualityБыстрая проверка записи из 6 или 6 + 1 отведений до анализа: годится ли она, и если нет — что поправить. Без очереди, не расходует лимит. Подробнее.
GET/jobsСписок ваших заданий, последние 100.
GET/jobs/{id}Статус задания. completed — готовы и JSON, и PDF.
GET/jobs/{id}/resultЗаключение в JSON: светофор, срочность, диагнозы с баллами, причины, качество записи. Плюс criteria — находки по формульным критериям, посчитанные по измерениям, и traffic_light_scope: ensemble+criteria, когда они учтены, или ensemble, пока criteria_status равен pending (отчёт ещё собирается).
GET/jobs/{id}/pdfPDF-отчёт на 9 страниц: кривые 12 отведений, измерения, заключение, ответы опроса.
GET/jobs/{id}/interviewТекущий вопрос опроса: id, text, признак safety.
POST/jobs/{id}/answersОтвет на текущий вопрос: JSON {"question_id": …, "answer": …}.
POST/jobs/{id}/finishЗавершить опрос досрочно — отчёт строится по уже данным ответам.

Жизненный цикл задания

  1. queued → processingЗапись в очереди и в анализе. Обычно 3–5 секунд.
  2. awaiting_interview ⇄ queued_answerТолько при interview=true: система задаёт от 3 до 15 вопросов, первыми — вопросы безопасности.
  3. queued_reportАнализ готов, формируется PDF на 9 страниц — ещё 1–2 секунды.
  4. completed или failedМожно забирать /result и /pdf. До этого оба метода отвечают 409; при failed причина в поле error.

Примеры кода

Полный цикл: отправить запись, дождаться готовности, забрать JSON и PDF. Ответы проверены на рабочем сервере.

curl

export BASE=https://kardiopilot.ru/api/v1
export TOKEN=kp_ваш_токен

# 0. проверить токен и лимиты
curl -H "Authorization: Bearer $TOKEN" "$BASE/me"

# 1. отправить запись (.npz, .csv, .edf или PDF кардиографа GE MUSE)
curl -H "Authorization: Bearer $TOKEN" -F "file=@ecg.npz" "$BASE/jobs"
# {"id":"xs7wVdOR…","status":"queued","links":{"self":…,"result":…,"pdf":…}}

# частоту и единицы CSV система определяет сама; укажите, только если она ошиблась:
curl -H "Authorization: Bearer $TOKEN" -F "file=@ecg.csv" -F fs=500 -F units=mV "$BASE/jobs"

# данные пациента — по желанию. Без них отчёт пишет «не указано» и берёт строгие пороги:
curl -H "Authorization: Bearer $TOKEN" -F "file=@ecg.csv"      -F "name=Пациент №42" -F sex=male -F age=40 -F height=182 -F weight=88 -F "bp=125/80"      "$BASE/jobs"

# 2. статус — повторять раз в 1–2 секунды до completed или failed
curl -H "Authorization: Bearer $TOKEN" "$BASE/jobs/$JOB"

# 3. заключение в JSON и PDF-отчёт
curl -H "Authorization: Bearer $TOKEN" "$BASE/jobs/$JOB/result"
curl -H "Authorization: Bearer $TOKEN" "$BASE/jobs/$JOB/pdf" -o report.pdf

Python (requests)

import time
import requests

BASE = "https://kardiopilot.ru/api/v1"
HEADERS = {"Authorization": "Bearer kp_ваш_токен"}


def analyse(path: str, patient: dict | None = None) -> dict:
    # 1. Отправить запись. Частота и единицы CSV определяются по файлу;
    #    передайте fs и units, только если определение ошиблось.
    #    patient: sex, age, height, weight, bp, name — всё по желанию.
    with open(path, "rb") as f:
        response = requests.post(f"{BASE}/jobs", headers=HEADERS, files={"file": f},
                                 data=patient or {}, timeout=60)
    response.raise_for_status()
    job_id = response.json()["id"]

    # 2. Дождаться готовности: обычно 5–10 секунд.
    while True:
        status = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS, timeout=30).json()
        if status["status"] == "completed":
            break
        if status["status"] == "failed":
            raise RuntimeError(f"Анализ не выполнен: {status['error']}")
        time.sleep(1.5)

    # 3. JSON-заключение.
    result = requests.get(f"{BASE}/jobs/{job_id}/result", headers=HEADERS, timeout=30).json()

    # 4. PDF-отчёт на 9 страниц.
    pdf = requests.get(f"{BASE}/jobs/{job_id}/pdf", headers=HEADERS, timeout=60)
    pdf.raise_for_status()
    with open(f"report-{job_id[:8]}.pdf", "wb") as out:
        out.write(pdf.content)
    return result


result = analyse("ecg.npz")
print(result["traffic_light"], result["urgency"])
for diagnosis in result["top_diagnoses"]:
    print(f"{diagnosis['name_ru']}: {diagnosis['score']:.2f}")

JavaScript (Node.js)

// Node.js 18+ (встроенные fetch, FormData и Blob)
import { readFile, writeFile } from "node:fs/promises";

const BASE = "https://kardiopilot.ru/api/v1";
const headers = { Authorization: "Bearer kp_ваш_токен" };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const form = new FormData();
form.append("file", new Blob([await readFile("ecg.csv")]), "ecg.csv");
// частота и единицы CSV определяются по файлу — эти поля нужны только для правки:
// form.append("fs", "500"); form.append("units", "mV");
form.append("sex", "male");   // данные пациента — по желанию
form.append("age", "40");

const created = await fetch(`${BASE}/jobs`, { method: "POST", headers, body: form });
if (!created.ok) throw new Error((await created.json()).detail);
const { id } = await created.json();

let status;
do {
  await sleep(1500);
  status = await (await fetch(`${BASE}/jobs/${id}`, { headers })).json();
} while (!["completed", "failed"].includes(status.status));
if (status.status === "failed") throw new Error(status.error);

const result = await (await fetch(`${BASE}/jobs/${id}/result`, { headers })).json();
console.log(result.traffic_light, result.primary_diagnosis?.name_ru);

const pdf = await fetch(`${BASE}/jobs/${id}/pdf`, { headers });
await writeFile("report.pdf", Buffer.from(await pdf.arrayBuffer()));

Опрос пациента

С параметром interview=true задание после анализа ждёт ответов. Варианты ответа: yes, probably_yes, unsure, probably_no, no. Ответы меняют оценку срочности и печатаются в PDF; баллы ЭКГ опрос не меняет. Брошенный опрос закрывается автоматически через 30 минут.

ANSWERS = ("yes", "probably_yes", "unsure", "probably_no", "no")

with open("ecg.npz", "rb") as f:
    job_id = requests.post(f"{BASE}/jobs", headers=HEADERS, files={"file": f},
                           data={"interview": "true"}, timeout=60).json()["id"]

while True:
    status = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS, timeout=30).json()["status"]
    if status in ("completed", "failed"):
        break
    if status != "awaiting_interview":      # queued, processing, queued_answer, queued_report
        time.sleep(1)
        continue
    state = requests.get(f"{BASE}/jobs/{job_id}/interview", headers=HEADERS, timeout=30).json()
    question = state["question"]
    # question["safety"] == True — вопрос безопасности (боль в груди, одышка, обморок)
    answer = ask_patient(question["text"])  # ваш интерфейс: одно из ANSWERS
    requests.post(f"{BASE}/jobs/{job_id}/answers", headers=HEADERS, timeout=30,
                  json={"question_id": question["id"], "answer": answer})

# Завершить опрос досрочно (отчёт построится по уже данным ответам):
# requests.post(f"{BASE}/jobs/{job_id}/finish", headers=HEADERS)

Что приходит в ответе

Сокращённый ответ GET /jobs/{id}/result. traffic_light — green, yellow или red; score — внутренний балл модели от 0 до 1, а не вероятность болезни. Значение assessment_status=complete означает пригодность анализа, а не отсутствие заболеваний; пропущенный интервал не означает норму.

{
  "id": "xs7wVdOR4hmT80NOZvWo08LOjqHdiGp_",
  "traffic_light": "yellow",
  "urgency": "abnormal_non_urgent",
  "assessment_status": "complete",
  "primary_diagnosis": {
    "code": "AF",
    "name_ru": "Фибрилляция предсердий",
    "score": 0.9918,
    "confidence": "requires_review",
    "traffic_light": "yellow",
    "confirming_models": ["inception1d", "lstm_bidir", "resnet1d101", "…"]
  },
  "top_diagnoses": [
    {"code": "AF", "name_ru": "Фибрилляция предсердий", "score": 0.9918, "…": "…"},
    {"code": "LVH", "name_ru": "Гипертрофия левого желудочка", "score": 0.0138, "…": "…"}
  ],
  "reasons": ["Результат локального ансамбля из шести моделей и XGBoost."],
  "interview_answered_count": 0,
  "quality_flags": ["…"],
  "criteria": [
    {"code": "LVH_CRITERIA",
     "name": "Признаки гипертрофии левого желудочка по вольтажным критериям",
     "detail": "Соколов–Лайон 64 мм"}
  ],
  "criteria_status": "ready",
  "traffic_light_scope": "ensemble+criteria",
  "calibration": {"version": "georgia_isotonic_v1", "engine_traffic_light": "yellow"},
  "measurement_consistency": {"…": "…"},
  "analysis_details": {"…": "…"},
  "pdf_url": "/api/v1/jobs/xs7wVdOR4hmT80NOZvWo08LOjqHdiGp_/pdf"
}

PDF отдаётся методом GET /jobs/{id}/pdf как файл application/pdf — его можно сохранить в карту пациента или отправить врачу без доработки.

Формат записи

NPZ

signal — матрица 12×N без NaN и бесконечностей.
fs — целая частота дискретизации, 100–1000 Гц.
lead_names — 12 названий отведений.
units — mV, uV или V.

CSV и EDF

CSV: 12 столбцов (по отведению на столбец), первая строка — названия I, II, III, aVR, aVL, aVF, V1…V6; разделитель — запятая, точка с запятой или табуляция. Частоту и единицы система определяет по файлу сама и пишет об этом в поле notice; поля fs и units нужны, только если определение ошиблось.
EDF/EDF+: 12 стандартных отведений, частота и единицы берутся из заголовка файла.

Данные пациента

В самой записи ЭКГ их нет, поэтому отчёт берёт их из полей формы: sex (male или female), age в годах, height в сантиметрах, weight в килограммах, bp строкой вида 125/80 и name — имя или идентификатор для шапки отчёта.
Все поля необязательны. От пола и возраста зависят пороги удлинения QT и подъёма ST, от роста и веса — площадь тела и критерии гипертрофии. Без них в отчёте стоит «не указано», а пороги берутся по более чувствительному варианту.

До 10 МиБ на файл, длительность 2–60 секунд (для 6 и 6 + 1 отведений — от 10 секунд, подробнее). Фотографии и сканы бумажной ленты, PDF и произвольные архивы не принимаются. Не передавайте ФИО и другие персональные данные: для анализа нужен только сигнал.

ЭКГ из 6 и 6 + 1 отведений

Те же вызовы и тот же формат. Режим определяется по сигналу: если записаны только I и II, это 6 отведений; если ещё одно грудное — 6 + 1. Незаписанные отведения передаются нулями. Рекомендуемая частота 500 Гц, длина записи 20 секунд, не меньше 10: анализ идёт по самому чистому отрезку 10 секунд. Что умеет этот режим →

Дополнительные поля NPZ

chest_lead — куда просили поставить грудной электрод, например V3; сигнал кладётся в эту же строку матрицы. Без поля плоское грудное отведение считается незаписанным, с ним — потерявшим контакт.
attempt — номер попытки записи: на третьей неудачной ответ «позовите сотрудника».
Оба поля можно передать и полями формы.

Проверка записи до анализа

POST /quality с тем же файлом отвечает за доли секунды, без очереди и без расхода лимита: status — ok, repeat или staff, reason — причина, electrode — какой электрод поправить, screen_text — готовый текст для экрана прибора. Анализ POST /jobs проверяет запись и сам: плохая запись не получает зелёный цвет.

{
  "status": "repeat",
  "reason": "contact",
  "electrode": "LA",
  "attempt": 1, "max_attempts": 3, "lead_mode": "6L+V3",
  "screen_text": "Нет контакта с левой рукой. Положите левую ладонь на датчик целиком и не отрывайте её.",
  "analysis_window_s": [4.0, 14.0],
  "chest_declared": "V3",
  "chest_candidates": ["V4"]
}

В chest_candidates — позиция грудного электрода, которую система признала по сигналу и по которой идёт анализ (заявленная или соседняя). Отчёт для 6 и 6 + 1 отведений — 8 страниц.

Лимиты и коды ответов

Лимиты

Суточный лимит заданий — по токену (для теста обычно 50).
Не больше 3 незавершённых заданий одновременно.
До 120 запросов в минуту с одного IP-адреса.
При превышении — ответ 429 и заголовок Retry-After.

Безопасность

Только HTTPS. Токен хранится на сервере в виде хеша, идентификаторы заданий случайные, чужие задания недоступны. Повторная отправка создаёт новое задание — после ошибки сети сначала проверьте GET /jobs.

КодКогда
200 / 202Успех; 202 — задание принято в очередь
400Файл не разобран или неверные параметры — причина в поле detail
401Нет токена, токен неверен, отозван или истёк
404Задание не найдено (или принадлежит другому клиенту)
409Результат или PDF запрошен до готовности; ответ не на текущий вопрос
413Файл больше 10 МиБ
415Неподдерживаемый формат файла
429Исчерпан лимит или очередь заполнена
503Сервис анализа временно недоступен