The Daily Newsstand · Free, Always
Friday, September 11, 2026

Как голосовой робот сам закрывает окна в расписании стоматологов

Translate

Представьте ситуацию: администратор стоматологической клиники утром открывает расписание и видит, что пациент отменил прием в 10:00, а следующее свободное окно у этого врача только вечером. Время пропадает, врач ждет. А рядом, в листе ожидания, наверняка есть человек, который был бы рад попасть именно на это время. Надо только, чтобы кто-то ему об этом сообщил. 

Почти каждый пятый пациент на прием не является. Вручную обзванивать долго и неэффективно, а слот нужно закрыть быстро, пока он еще актуален.

Дальше покажем, как автоматизировать управление записями к стоматологу. Голосовой робот МТС Exolve будет сам звонить пациенту, предлагать перенести визит и сохранит изменения в расписании.

Стек:

Python 3.10+, FastAPI, SQLite, Pydantic Settings, Голосовой робот МТС Exolve.

Общая схема работы

Все начинается с медицинской информационной системы (МИС), в которой ведется расписание врачей. Когда пациент отменяет прием, она отправляет уведомление с данными о враче и временем освободившегося окна. Затем бэкенд перепроверяет статус записи в МИС, чтобы убедиться, что прием и правда отменен, а место за прошедшее время не занял кто-то другой.

Когда проверки пройдены, система создает список кандидатов. Сначала она выбирает пациентов из листа ожидания к специалисту, у которого была отмена, затем тех, кто записан на более поздние даты. А перед звонком список прогоняют еще раз, чтобы не дергать пациента, который уже сам перенес прием или отменил его.

Дальше в дело вступает голосовой робот. Он звонит — и пациент либо соглашается, либо отказывается, либо вообще не берет трубку. Если пациент сказал «да», система сразу бронирует окно и создает новый визит. Отказался или не взял трубку — робот записывает статус, а сервис набирает следующего кандидата в очереди.

На панели администратора видно три сценария для каждого окна: прием переназначен, слот заняли до того, как дошла очередь звонить, или кандидаты закончились.

Как все устроено

Бэкенд соединяет МИС и голосового робота. FastAPI принимает вебхуки об отмене записи и проверяет доступность слота, подбирает кандидатов и запускает обзвон.

Состояние системы (данные об окнах, кандидатах и история обращений) хранится в SQLite. Благодаря ключам slot_id, candidate_id, appointment_id и call_id система удерживает контекст, даже если ответ от робота запаздывает. Расписанием управляет ClinicApi — в этом проекте мы используем мок-клиент поверх SQLite.

Голосовой робот МТС Exolve обзванивает кандидатов одного за другим. Согласие — новая запись; отказ или молчание — система запоминает причину и переходит к следующему пациенту.

Общая схема работы

Логика разбита на несколько функциональных блоков:

  • app.py — принимает входящие вебхуки, проверяет токены доступа, отдает команды для управления сервисом;

  • slot_filling_service.py — держит на себе основной сценарий: следит за статусами окон, выбирает пациентов, фиксирует результаты звонков;

  • clinic_api.py — синхронизирует данные с расписанием медицинской системы;

  • reaper.py — находит и закрывает звонки, по которым робот так и не прислал итоговый статус из-за технических сбоев;

  • config.py — хранит настройки окружения, лимиты и параметры фоновой обработки.

Что и как мы храним

Все начинается с вебхука от МИС об отмене записи. Затем процесс идет асинхронно: робот звонит, а результат присылается отдельным запросом. Чтобы между этими событиями не потерять контекст, бэкенд держит состояние: свободное окно, выбранного кандидата и условия переноса.

Данные по отмененным слотам (специалист, услуга, время, длительность) лежат в таблице canceled_slots. По статусу видно, чем все закончилось.

Данные по пациентам хранятся в таблице slot_candidates: каждый кандидат привязан к свободному окну и своему текущему визиту. Статус показывает этап: в очереди, идет обзвон, согласие, отказ или пропуск (запись устарела).

Все звонки к боту логируются в call_attempts. Бэкенд хранит идентификатор звонка и находит правильный слот и кандидата по ID, который ловит в вебхуке. Так система остается на связи, даже если платформа отвечает с опозданием.

Управление окном и обзвон мы разделили. У одного слота может быть несколько кандидатов, поэтому сервис логирует попытки связаться с каждым из них. 

Целостность и статусы

Худшее, что может случиться, — подтверждение пациенту занятого слота. Чтобы этого избежать, бэкенд перепроверяет его три раза: при отмене, перед обзвоном и после согласия пациента. 

Сервис обрабатывает все входящие уведомления ровно один раз. Бэкенд забирает ключ из параметров события и ищет по нему существующий canceled_slot. Если МИС пришлет вебхук еще раз, то обработчик отдаст созданную задачу.

Перед звонком система прогоняет список через skipineligible_pending и отсеивает тех, чьи данные успели устареть, пока строилась очередь. Например, пациент мог перенести визит через администратора и параллельно его могли пометить как отказавшегося от авторассылки.

Необходимые компоненты 

Сервис работает на Python 3.10+. Из зависимостей у него FastAPI, Uvicorn и библиотека для настроек окружения.

python -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn pydantic-settings requests 

Теперь поженим все с голосовым роботом МТС Exolve. Через конструктор создадим сценарий обзвона, где робот будет предлагать пациентам перенести запись пораньше. Для этого используем HTTPS-адрес, куда робот будет отправлять результаты звонков. URL API и токен доступа сохраняем в переменных окружения, заодно проверяем, что МИС умеет отправлять на ваш FastAPI-сервис вебхук об отмене записи.

Создаем файл .env со следующими параметрами и подставьте реальные значения вместо демонстрационных:

APP_NAME="Dental Slot Filler"
DB_PATH="./dental_slots.sqlite3"
CLINIC_NAME="Дентал Клиник"
WEBHOOK_TOKEN="change-me"
ROBOT_WEBHOOK_TOKEN="change-me"
ADMIN_TOKEN="change-me"
ROBOT_API_URL=""
ROBOT_API_TOKEN=""
PUBLIC_BASE_URL="https://example.com"
RECENT_CALL_HOURS=24
AUTO_CALL_NEXT_CANDIDATE=true
CALL_TIMEOUT_MINUTES=15
REAPER_INTERVAL_SECONDS=60
RUN_INPROCESS_REAPER=true
ENABLE_DEMO_ENDPOINTS=true

Шаг 1. Обработка вебхука об отмене записи

FastAPI принимает вебхук от МИС с идентификатором записи, данными врача, датой, временем и длительностью окна. По этим параметрам сервис находит отмененный визит. 

app.py
@app.post(
    "/webhooks/appointment-canceled",
    response_model=CancellationResponse,
    dependencies=[Depends(require_webhook_token)],
)
def appointment_canceled(payload: CancellationWebhook):
    try:
        return service.handle_cancellation(payload)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

Сначала эндпоинт проверяет токен в заголовках, и если все в порядке, передает данные в сервис управления слотами. Невалидные данные сразу отсекаются ошибкой 400, после проверки приложение открывает транзакцию и начинает работу с внутренними сущностями в базе.

Чтобы один и тот же вебхук не вызывал несколько обзвонов, используемся ключ идемпотентности, который бэкенд вычисляет и проверяет canceled_slot. Если он есть, то сценарий не запускается.

slot_filling_service.py
starts_at = datetime.combine(payload.date, payload.time).isoformat(timespec="seconds")
idempotency_key = self._make_idempotency_key(payload, starts_at)
existing = conn.execute(
    """
    SELECT id, status, stop_reason
    FROM canceled_slots
    WHERE idempotency_key = ?
    """,
    (idempotency_key,),
).fetchone()
if existing:
    return {
        "slot_id": existing["id"],
        "status": existing["status"],
        "stop_reason": existing["stop_reason"],
        "candidates_created": 0,
    }

Если слот уже отменен, обработчик новых задач не запускает и возвращает статус и причину.

Шаг 2. Проверяем доступность слота

Даже если вебхук говорит об отмене, то время все равно может быть занято. Поэтому сервис снова запрашивает данные о приеме, попутно сверяя статус, врача и длительность окна.

slot_filling_service.py
canceled_appointment = self.clinic_api.get_appointment(
    conn,
    payload.canceled_appointment_id,
)
if not canceled_appointment:
    raise ValueError(
        f"Canceled appointment not found: {payload.canceled_appointment_id}"
    )
if canceled_appointment["status"] != "canceled":
    raise ValueError(
        f"Appointment is not canceled: {payload.canceled_appointment_id} "
        f"(status={canceled_appointment['status']})"
    )
if canceled_appointment["doctor_id"] != payload.doctor_id:
    raise ValueError("Canceled appointment doctor mismatch")
if canceled_appointment["duration_minutes"] != payload.duration_minutes:
    raise ValueError("Canceled appointment duration mismatch")
Затем ClinicApi проверяет, есть ли у врача активные визиты кроме самой отмененной записи. 
clinic_api.py
row = conn.execute(
    f"""
    SELECT id
    FROM appointments
    WHERE doctor_id = ?
      AND status = ?
      AND datetime(starts_at) < datetime(?)
      AND datetime(starts_at, '+'  duration_minutes  ' minutes') > datetime(?)
      {ignore_sql}
    LIMIT 1
    """,
    params,
).fetchone()
return row is None

Если время занято, бэкенд закрывает задачу со статусом occupied и причиной slot_already_occupied. 

Шаг 3. Создание задачи и выбор кандидатов

После подтверждения окна бэкенд создает запись в canceled_slots и начинает подбор пациентов. Для освободившегося слота система записи с подходящими параметрами: теми же врачом, услугой и длительностью.

slot_filling_service.py
slot_id = self._create_canceled_slot(
    conn,
    idempotency_key=idempotency_key,
    canceled_appointment_id=payload.canceled_appointment_id,
    doctor_id=payload.doctor_id,
    service_id=service_id,
    starts_at=starts_at,
    duration_minutes=payload.duration_minutes,
    status="task_created",
    stop_reason=None,
)
candidates_created = self._create_candidates(conn, slot_id=slot_id)

Сначала сервис проверяет лист ожидания и, если подходящих людей нет, находит записи на более поздние даты к этому же врачу и предлагает перенести визит пораньше. 

slot_filling_service.py
def createcandidates(self, conn: Connection, *, slot_id: int) -> int:
    slot = self._get_slot(conn, slot_id)
    if not slot:
        raise ValueError(f"Slot not found: {slot_id}")
    created_from_waiting_list = self._create_candidates_from_waiting_list(conn, slot)
    if created_from_waiting_list > 0:
        return created_from_waiting_list
    return self._create_candidates_from_later_appointments(conn, slot)

Еще до первого звонка система через SQL-фильтры отсеивает тех, кто уже отказывался от обзвона, кому нельзя звонить по этой записи, кому уже предлагали этот слот и кто получал звонки в течение недавнего периода RECENT_CALL_HOURS. 

slot_filling_service.py
WHERE wl.doctor_id = ?
  AND wl.service_id = ?
  AND wl.active = 1
  AND a.status = 'scheduled'
  AND datetime(a.starts_at) > datetime(?)
  AND a.duration_minutes = ?
  AND p.global_opt_out = 0
  AND a.offer_opt_out = 0
  AND NOT EXISTS (
      SELECT 1
      FROM offered_slots os
      WHERE os.slot_id = ?
        AND os.patient_id = wl.patient_id
  )
  AND NOT EXISTS (
      SELECT 1
      FROM call_attempts ca
      WHERE ca.patient_id = wl.patient_id
        AND datetime(ca.created_at) >= datetime(?)
  )
ORDER BY wl.created_at ASC

Если подходящих кандидатов нет, сервис завершает задачу без звонков, чтобы не беспокоить пациентов лишний раз. 

Шаг 4. Запуск звонка через голосового робота

Перед обзвоном список кандидатов проверяется еще раз. Расписание клиники вполне могло измениться с формирования очереди. Администратор мог перенести визит вручную, а пациент — попасть в список отказов при обработке соседнего слота. 

if slot["status"] in TERMINAL_SLOT_STATUSES:
    return {
        "slot_id": slot_id,
        "status": slot["status"],
        "action": "nothing_to_call",
    }
self._skip_ineligible_pending(conn, slot)
candidate = conn.execute(
    """
    SELECT
        sc.*,
        p.full_name AS patient_name,
        p.phone AS patient_phone,
        d.full_name AS doctor_name,
        a.starts_at AS current_appointment_starts_at
    FROM slot_candidates sc
    JOIN patients p ON p.id = sc.patient_id
    JOIN doctors d ON d.id = (
        SELECT doctor_id FROM canceled_slots WHERE id = sc.slot_id
    )
    JOIN appointments a ON a.id = sc.appointment_id
    WHERE sc.slot_id = ?
      AND sc.status = 'pending'
    ORDER BY sc.id
    LIMIT 1
    """,
    (slot_id,),
).fetchone()

После выбора кандидата бэкенд меняет у него статус звонка и создает идентификатор звонка. Вместе с запросом отправляется набор идентификаторов: слот, кандидат, пациент, запись. Когда робот ответит, система поймет, к какому именно окну относится результат.

metadata = {

    "slot_id": slot_id,

    "candidate_id": candidate["id"],

    "patient_id": candidate["patient_id"],

    "appointment_id": candidate["appointment_id"],

}

self.robot_client.start_offer_call(

    call_id=call_id,

    patient_phone=patient_phone,

    phrase=phrase,

    metadata=metadata,

)

Если API телефонии вернет ошибку, то бэкенд отметит неудачную попытку и перейдет к следующему пациенту. 

Шаг 5. Обработка результата

Робот на вебхук присылает результат звонка, затем бэкенд по идентификатору находит нужную попытку и подтягивает параметры окна — время, врача, длительность. Без этого система не сможет применить результат к расписанию.

metadata = {
    "slot_id": slot_id,
    "candidate_id": candidate["id"],
    "patient_id": candidate["patient_id"],
    "appointment_id": candidate["appointment_id"],
}
self.robot_client.start_offer_call(
    call_id=call_id,
    patient_phone=patient_phone,
    phrase=phrase,
    metadata=metadata,
)d = ?
    """,
    (payload.call_id,),
).fetchone()
if not call:
    raise ValueError(f"Call not found: {payload.call_id}")

Сначала система проверяет статус звонка. Если вебхук пришел повторно или фоновый процесс уже закрыл попытку по таймауту, сервис просто сообщает, что обработка уже завершена, и ничего не меняет в базе.

if call["status"] != "started":
    return {
        "call_id": payload.call_id,
        "result": payload.result,
        "action": "already_processed",
        "slot_status": call["slot_status"],
    }

Ответ робота переводит задачу в одно из состояний:

  • accepted — пациент подтвердил перенос;

  • refused_allow_future — пациент отказался от предложения, но готов к звонкам в будущем;

  • refused_appointment_opt_out — клиент запретил предлагать новые варианты для этой записи;

  • do_not_call_appointment — клиент запретил обзвон по этой записи;

  • do_not_call_global — пациент попросил больше не звонить;

  • no_answer — номер не ответил или соединение прервалось;

  • unclear_end — робот не распознал ответ, запись остается без изменений.

if payload.result == RobotResult.accepted:
    action = self._handle_accept(conn, call)
elif payload.result == RobotResult.refused_allow_future:
    action = self._handle_refused_allow_future(conn, call)
elif payload.result == RobotResult.refused_appointment_opt_out:
    action = self._handle_appointment_opt_out(conn, call)
elif payload.result == RobotResult.do_not_call_appointment:
    action = self._handle_appointment_opt_out(conn, call)
elif payload.result == RobotResult.do_not_call_global:
    action = self._handle_global_opt_out(conn, call)
elif payload.result == RobotResult.no_answer:
    action = self._handle_no_answer(conn, call)
elif payload.result == RobotResult.unclear_end:
    action = self._handle_unclear(conn, call)

При отказе система записывает причину и переходит к следующему в очереди. 

Шаг 6. Перенос записи после повторной проверки

Если пациент ответил роботу утвердительно, то бэкенд повторно запрашивает информацию о текущем визите, а обработчик заново читает appointment.

appointment = self.clinic_api.get_appointment(conn, call["appointment_id"])
if (
    appointment is None
    or appointment["status"] != "scheduled"
    or appointment["doctor_id"] != call["slot_doctor_id"]
    or appointment["service_id"] != call["slot_service_id"]
    or appointment["duration_minutes"] != call["slot_duration_minutes"]
):
    conn.execute(
        """
        UPDATE slot_candidates
        SET status = 'skipped',
            result = 'appointment_changed_during_call',
            updated_at = CURRENT_TIMESTAMP
        WHERE id = ?
        """,
        (call["candidate_id"],),
    )
    self._update_slot_status(
        conn,
        slot_id=call["slot_id"],
        status="calling",
        stop_reason=None,
    )
    return "appointment_changed_skip"

Если запись изменилась, система помечает кандидата как пропущенного и возвращает слот в очередь обзвона. Так она защищена от конфликтов. 

Затем сервис проверяет, доступно ли освободившееся окно. Если за время звонка место оказалось занятым, то слот закрывается, а сценарий прекращается. 

При правильности данных ClinicApi переносит визит двумя операциями в SQLite. Старая запись отмечается как перенесенная, а новая создается на освободившееся время и связывается с идентификатором слота. old_appointment_id взят из карточки кандидата и обозначает идентификатор текущего визита пациента. 

conn.execute(
    """
    UPDATE appointments
    SET status = 'moved'
    WHERE id = ?
    """,
    (old_appointment_id,),
)
conn.execute(
    """
    INSERT INTO appointments (
        id,
        patient_id,
        doctor_id,
        service_id,
        starts_at,
        duration_minutes,
        status,
        offer_opt_out,
        moved_from_slot_id
    )
    VALUES (?, ?, ?, ?, ?, ?, 'scheduled', 0, ?)
    """,
    (
        new_appointment_id,
        old["patient_id"],
        old["doctor_id"],
        old["service_id"],
        new_starts_at,
        duration_minutes,
        slot_id,
    ),
)

В конце бэкенд фиксирует успешное заполнение окна и подтверждает выбор кандидата. Остальные претенденты исключаются, чтобы голосовой робот не предлагал им занятое время. 

Шаг 7. Обработка незавершенных вызовов

Проблемы со связью могут приводить к потере вебхука и зависанию статуса вызова. Попытка может застрять в статусе started, а сервис без сигнала о завершении звонка не перейдет к следующему кандидату. 

Принудительно завершает устаревшие сессии метод expire_stale_calls. Он находит звонки, время ожидания по которым превысило лимит CALL_TIMEOUT_MINUTES, переводит попытку в статус timeout, а кандидата переводит в категорию «нет ответа». Так система продолжает обзвон пациентов дальше. 

slot_filling_service.py
stale = conn.execute(
    """
    SELECT id, slot_id, candidate_id, call_id
    FROM call_attempts
    WHERE status = 'started'
      AND datetime(created_at) < datetime(?)
    """,
    (cutoff,),
).fetchall()
for row in stale:
    conn.execute(
        """
        UPDATE call_attempts
        SET status = 'timeout',
            robot_result = 'timeout',
            updated_at = CURRENT_TIMESTAMP
        WHERE id = ?
        """,
        (row["id"],),
    )
    conn.execute(
        """
        UPDATE slot_candidates
        SET status = 'no_answer',
            result = 'call_timeout',
            updated_at = CURRENT_TIMESTAMP
        WHERE id = ?
          AND status = 'calling'
        """,
        (row["candidate_id"],),
    )
conn.commit() 

Очистка работает в двух режимах. В простом варианте задачу выполняет фоновый поток внутри приложения. При масштабировании лучше запустить отдельный процесс reaper.py, чтобы воркеры FastAPI не пытались одновременно обработать одни и те же звонки.

slot_filling_service.py
stale = conn.execute(
    """
    SELECT id, slot_id, candidate_id, call_id
    FROM call_attempts
    WHERE status = 'started'
      AND datetime(created_at) < datetime(?)
    """,
    (cutoff,),
).fetchall()
for row in stale:
    conn.execute(
        """
        UPDATE call_attempts
        SET status = 'timeout',
            robot_result = 'timeout',
            updated_at = CURRENT_TIMESTAMP
        WHERE id = ?
        """,
        (row["id"],),
    )
    conn.execute(
        """
        UPDATE slot_candidates
        SET status = 'no_answer',
            result = 'call_timeout',
            updated_at = CURRENT_TIMESTAMP
        WHERE id = ?
          AND status = 'calling'
        """,
        (row["candidate_id"],),
    )
conn.commit() 

Запуск и проверка

Настраиваем файл конфигурации и запускаем FastAPI-приложение.

cp .env.example .env
uvicorn app:app --reload --port 8000

В демонстрационном режиме наполняем базу тестовыми данными через эндпоинт /demo/seed. Чтобы можно было проверить сценарий перераспределения слотов, система создаст профили врачей, список услуг и расписание. 

curl -X POST "http://localhost:8000/demo/seed" \
  -H "X-Admin-Token: change-me"
Имитируйте отмену визита в МИС: отправьте вебхук с параметрами освободившегося слота. 
curl -X POST "http://localhost:8000/webhooks/appointment-canceled" \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Token: change-me" \
  -d '{
    "canceled_appointment_id": "appt_cancelled",
    "doctor_id": "doctor_1",
    "date": "2026-06-20",
    "time": "10:00:00",
    "duration_minutes": 60
  }'

Бэкенд вернет идентификатор задачи, ее статус и количество найденных кандидатов. Запросить состояние слота и историю вызовов можно через служебный эндпоинт. 

curl "http://localhost:8000/slots/1" \
  -H "X-Admin-Token: change-me"

После подтверждения от пациента, сервис обновляет статус старого визита и бронирует новое время. Могут быть ответы с такими кодами ошибок:

401 — проблема с токенами авторизации;
400 — данные в вебхуке не соответствуют записям в базе;
404 — идентификатор звонка не найден в истории попыток. 

По сути сервис несколькими путями проверяет, не отменил ли кто-нибудь прием. Медицинская система сообщает об этом напрямую, FastAPI трижды убеждается, что слот и правда свободен, база хранит очередь кандидатов. И только после этого голосовой робот МТС Exolve обзванивает людей и возвращает результат через вебхук. Вся сложность здесь, с одной стороны, в необходимости всеми правдами и неправдами избежать подтверждения занятого времени, а с другой — контролировать, чтобы робот отвечал вовремя.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.