Мы продолжаем обновлять Voximplant Kit с помощью JointJS. И рады сообщить о появлении «живых» логов (live logs) звонков. Насколько они живые и опасны ли для простых юзеров, читайте под катом.

Ранее для анализа звонков в Voximplant Kit пользователям были доступны лишь записи разговоров. Нам же хотелось в дополнение к аудио сделать не просто текстовый лог, а более удобный инструмент для просмотра деталей звонка и анализа ошибок. И поскольку мы имеем дело с low-code/no-code продуктом, появилась идея визуализации логов.

В чем соль?/ Новый концепт


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


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


Управление


Контролы старт\стоп (1) останавливают/возобновляют воспроизведение, а назад\далее (2) точечно перемещают юзера к началу следующего/предыдущего блока. Можно также просто кликать по таймлайну, чтобы начать воспроизведение с определенного момента времени, как с проигрыванием песни.

Если сценарий включает в себя запись разговора, то она будет проигрываться параллельно с перемещением по блокам. Аудиозапись на таймлайне выделена отдельным цветом (3).


Для удобства пользователя также доступен список пройденных блоков с таймстампами («Лог»):


Спойлер:
Во вкладке «Лог» мы планируем показывать детали блоков. Они помогут нам понять, почему из блока вышли по определенному порту и были ли ошибки. Например, для блока распознавания мы увидим результаты и ошибки распознавания.
Наибольший интерес здесь будут представлять сложные блоки, такие как DialogFlowConnector, IVR, ASR и т.д.


Переменные


Измененные переменные отображаются слева в виде уведомлений, всплывающих согласно хронологии. То есть если мы переместимся на блок «Изменение данных» – всплывут переменные, которые менялись там. Далеко уйдем от него (более 4с по таймлайну) – переменные исчезнут до тех пор, пока мы снова не окажемся на промежутке, где произошло изменение:



Лайфхак


Логи звонков сохраняются в первоначальном виде, даже после изменения или удаления сценария. А это значит, что не возникнет проблем в восстановлении хода звонка: если он понадобится, всегда можно обратиться к логу.

Самостоятельно пощупать логи можно на Voximplant Kit.

Так, а что внутри?


Разберемся, как именно динамические логи реализованы в коде. Скажем сразу, от Joint JS мы взяли лишь анимацию и выделение блоков, как в деморежиме. Остальное (что можно на основе этого сделать) – наша фантазия.

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

Получаем timepoint’ы


При переходе в просмотр лога, сервер присылает данные, где содержится список всех пройденных блоков, время входа в них и список переменных, которые менялись во время звонка. Другими словами, на фронте мы получаем два массива объектов: log_path и log_variables.

Также в ответе сервера есть ссылка на аудио и его продолжительность, если разговор записывался.


На основе времени входа в блоки мы рассчитываем timepoint’ы в миллисекундах и записываем их для каждого блока и переменных. Точка отсчета – время входа в первый блок. Далее ищем блок с аудиозаписью и используем его, чтобы получить время старта воспроизведения аудио. Используя эти timepoint'ы, рассчитываем полную продолжительность лога.

Обновляем временную шкалу


После нажатия кнопки play временная шкала начинает обновляться каждые 10 мс. Во время каждого обновления проверяем, совпадает ли текущее время с одним из timepoint’ов:

const found = this.timePoints.find((item) => item === this.playTime);

Если совпадение есть, будем искать все блоки у которых timepoint = текущее время + 600 мс (время, за которое происходит анимация перемещения между блоками).

Код метода updatePlayTime():

updatePlayTime(): void {
    const interval = 10;
    let expected = Date.now() + interval;

    const tick = () => {
        const drift = Date.now() - expected;
        const found = this.timePoints.find((item) => item === this.playTime);
        this.$emit('update', {
            time: this.playTime,
            found: found !== undefined
        });

        if (this.playTime >= this.duration) {
            this.isPlay = false;
            this.playTime = this.duration;
            clearTimeout(this.playInterval);
            this.$emit('end', this.playTime);
            return;
        }

        expected += interval;

        this.playTime += 0.01;
        this.playTime = +this.playTime.toFixed(2);

        this.updateProgress();

        this.playInterval = window.setTimeout(tick, Math.max(0, interval - drift));
    };

    this.playInterval = window.setTimeout(tick, 10);
}

Так же каждые 90 мс мы проверяем совпадения для текущего времени и timepoint'ов у измененных переменных + 4000 мс (время, в течение которого висит уведомление об изменении переменной).

Выделяем блоки


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

Если блоков с timepoint = текущее время + 600 мс несколько, то переход анимируется только к последнему:

if (i === blocks.length - 1) {
    await this.selectBlock(blocks[i], 600, true, true);
}

Это необходимо, поскольку есть блоки, которые обрабатываются очень быстро. Например, «Проверка данных», «Изменение данных» и т.п. – за 1 секунду может быть пройдено сразу несколько блоков. Если их анимировать последовательно, то возникнет отставание от времени таймлайна.

Код метода onUpdateTimeline:

async onUpdateTimeline({
    time,
    found
}) {
    this.logTimer = time * 1000; // конвертируем в мс
    this.checkHistoryNotify();

    if (!found) return;

    // Выделяем группу блоков от первой найденной точки + 600мс
    const blocks = this.callHistory.log_path.filter((item) => {
        return item.timepoint >= this.logTimer && item.timepoint < this.logTimer + 600;
    });

    if (blocks.length) {
        this.editor.unselectAll();

        for (let i = 0; i < blocks.length; i++) {

            if (i === blocks.length - 1) {
                await this.selectBlock(blocks[i], 600, true, true);

                const cell = this.editor.getCellById(blocks[i].idTarget);
                this.editor.select(cell);
            } else {
                await this.selectBlock(blocks[i], 0, false, true);
            }
        }
    }
}

И так по кругу, есть совпадение – выделяем блоки, если блок в уже в очереди – ничего не делаем.

В этом нам помогает метод selectBlock():

async selectBlock(voxHistory, timeout = 700, animate = true, animateLink = true) {
    const inQueue = this.selectQueue.find((item) => item[0].targetId === voxHistory.idTarget);

    if (!inQueue) this.selectQueue.push(arguments);

    return this.exeQueue();
}

Перематываем


При перемотке тот же принцип: когда таймлайн переместили, мы получаем время, на которое нужно перемотать и отмечаем блоки с timepoint'ами меньше текущего времени как пройденные:

const forSelect = this.callHistory.log_path.filter((item) => {
        const time = accurate ? item.accurateTime : item.timepoint;
        return time <= this.logTimer;
    });

Анимированный переход делаем к последнему из них.

Код метода onRewind():

async onRewind({
    time,
    accurate
}, animation = true) {
    this.editor.unselectAll();
    this.stopLinksAnimation();
    this.checkHistoryNotify(true);

    const forSelect = this.callHistory.log_path.filter((item) => {
        const time = accurate ? item.accurateTime : item.timepoint;
        return time <= this.logTimer;
    });

    for (let i = 0; i < forSelect.length; i++) {
        if (i === forSelect.length - 1) {
            await this.selectBlock(forSelect[i], 600, animation, false);
            const cell = this.editor.getCellById(forSelect[i].idTarget);
            this.editor.select(cell);
        } else {
            await this.selectBlock(forSelect[i], 0, false, false);
        }
    }

    if (this.isPlay) this.restartAnimateLink();

    this.onEndTimeline();
}

Проигрываем аудио


С включением/выключением аудиозаписи дела обстоят еще проще. Если время таймлайна совпадает со стартом записи, она начинает проигрываться и далее время синхронизируется. За это отвечает метод updatePlayer():

updatePlayer() {
    if (this.playTime >= this.recordStart && this.player.paused && !this.isEndAudio) {
        this.player.play();
        this.player.currentTime = this.playTime - this.recordStart;
    } else if (this.playTime < this.recordStart && !this.player.paused) {
        this.player.pause();
    }
}

На этом всё! Вот так на основе методов Joint JS и креатива наших разработчиков появились живые логи. Обязательно протестируйте их самостоятельно, если вы этого еще не сделали :)

Здорово, если вам нравится наша серия статей про обновления Кита. Будем и дальше делиться с вами самым свежим и интересным!