Разработали речевую аналитику для контакт-центра. Распознавание речи через сервис Yandex Speechkit, а анализ полученного текста уже в контуре, на нашем решении. По ходу разработки встречались интересные моменты, которые постараюсь описать.

1. Yandex Speechkit. Распознает речь в текст очень хорошо, по моему для казахского языка на сегодня вообще один из лучших сервисов, по качеству распознавания. Попадались тихие аудио записи, когда абонента еле слышно, но сервис распознавания справлялся. На мой взгляд, не хватает только списка языков, которые сервис распознал, в ответе. Так как в Казахстане все диалоги могут быть смешанными, казахский и русский языки, то на распознавание передаем с параметром lang = auto. И если сервис разобрался, какие в треке встретились языки, то почему бы не вернуть в ответе массив типа [ ru, kz ], или только [ kz ]. или только [ ru ]. Но пока такой опции нет, хотя команда Яндекс обещает.

2. Проигрыватель аудио в карточке расшифровки. Карточка расшифровки выглядит как все карточки расшифровки в аналитиках ))), вверху информация об аудио записи, и бегунок проигрывателя, внизу реплики диалога.

По клику на реплике нужно, чтобы проигрыватель переходил на соответствующий реплики участок аудио. Это оказалось просто, считываем из документа элемент audio, и у этого элемента есть нужны методы. К репликам добавили поле start_time, в секундах от начала аудио записи. По клику на реплике вызывается метод элемента audio, в него передаем нужные секунды:

this.audio = document.querySelector('audio');

playAudioOnClickOnItem(time: number) {
        this.audio.currentTime = time; // В секундах
        this.audio.play();
}

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

4. Редактирование слов в фрагменте. Фрагменты нужны чтобы составить скрипт разговора, для контроля соблюдения скриптов, в диалоге. Фрагмент состоит из набора слов. Фронт получает от бека сущность фрагмента, внутри сущности есть массив слов, входящих в этот фрагмент. На фронте сделали форму, в которой пользователь может изменить набор слов, исправить слова, и т.п. По нажатию кнопки «Сохранить» фрагмент с новым массивом слов отправляется на бек.

5. Выбор колонок в реестре записей. Обычно в реестре записей не очень много колонок, поэтому набор колонок статичный. Но в случае расшифровки аудио, полей у сущности может быть очень много. Начиная от «Входящий» или «Исходящий» звонок, до департамента, города, баллов за звонок, количество слов с одной стороны, и т. д. Поэтому в реестре звонков в аналитике должен быть выбор колонок, которые пользователь считает нужными. Единственная колонка статичная (обязательная) это кликабельная колонка с ID звонка, по которой проваливаемся в карточку звонка.

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

6. JWT и учетка пользователя. В интернетах много статей и видео про авторизацию и роли в NestJs, но часто тема не раскрыта. Обычно автор объясняет, как создать токен JWT, объявляет strategies и guards, и показывает, что авторизация работает, и на этом закачивает. Но это ведь где то 5-10% от темы авторизации и роли пользователя. Начать с того, что на беке на момент запроса нужно понять, что это за пользователь, и дальше уже с этим работать.

Для этого объявляем простой декоратор. В этом примере учетка пользователя равна его email. Декоратор выбирает те поля, которые есть в payload внутри JWT токена. Если у нас содержимое JWT токен:

async login(user: any) {

    const payload = {
      email: user.email,
      username: user.username,
      role: user.role
    };

    return {
      access_token: this.jwtService.sign(payload),
    };
}

То в декораторе мы можем вытащить любое поле из payload, например email:

export const User = createParamDecorator(
  (data: any, ctx: ExecutionContext): string => {
  const request = ctx.switchToHttp().getRequest();

  if(
    request.user !== undefined
    &&
    request.user.email !== undefined
  )
  {
    return request.user.email;
  }
  else {
    return 'unknown_user'
  }
});

Далее в контроллере мы получаем данные пользователе, и что-то уже можем с этим делать, например проверить права доступа на чтение записей:

import { User } from '../user/user.decorator';

  @Get()
  @ApiOperation({ summary: 'Получить список разговоров' })
  @ApiOkResponse({ description: 'Список разговоров', type: SpeechEntity, isArray: true })
  findAll(@User() userMail: string): Promise<SpeechEntity[]> {
    return this.speechService.findAll( userMail );
  }

7. Роли и права доступа — самая спорная тема. Возникает ряд вопросов:

  1. Где в архитектуре проекта NestJs правильнее проверять права доступа? В контроллере или в уже в сервисе?

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

@Get()
@ApiOperation({ summary: 'Получить список контактов' })
@ApiOkResponse({ description: 'Список контактов', type: ContactEntity, isArray: true })
findAll( @User() userMail: string ): Promise<ContactEntity[]> {
    const userAccessWhereOptions = this.userService.getUserAccessWhereOptions( userMail, controllerName );
    return this.contactService.findAll( userAccessWhereOptions);
}

  1. Но внутри сервиса findAll могут быть свои условия для поиска, например category = new, которые придется пристегнуть к условиям поиска по ответственному. А еще может быть такое, что «свои» записи, это где пользователь или ответственный, или автор. Тогда в сервис findAll попадет два условия поиска с ИЛИ, и придется добавлять условиям выборки category = new к каждому из этих двух условий.

  2. А еще есть удаление записей, которое на самом деле не удаление, а deleted = yes. И обычный пользователь видит «свои» записи при условии deleted = no, а админ видит все записи.

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

where = [
        {
          'owner': userEmail, // Ответственный за запись
          'deleted': 'no'
        }, // ИЛИ
        {
          'father': userEmail, // Создатель записи
          'deleted': 'no'
        }
];

А внутри findAll есть функция, которая перебирает все условия и добавляет к ними собственные условия:

async findAll( userAccessWhereOptions: {}[] ): Promise<ContactEntity[]> {
    const options: FindManyOptions = {
      where: UtilWhereAssign(
        userAccessWhereOptions,
        {
          'category': 'new'
        }
      ),
      order: { updated: 'DESC' },
    };
  const result = await this.contactRepository.find( options );
  return result;
}

Из UtilWhereAssign для «обычного пользователя» вернется новое условие поиска:

where = [
        {
          'owner': userEmail,
          'deleted': 'no',
          'category': 'new'
        }, // ИЛИ
        {
          'father': userEmail,
          'deleted': 'no',
          'category': 'new'
        }
];

7. На фронте учитываем права пользователя. Но еще как-то нужно сообщить пользователю на фронте, что у него нет прав на запрашиваемую операцию. На беке есть метод, который возвращает роль пользователя. Соответственно, если у пользователя нет прав на добавление записей, то фронт должен отключить кнопку «Добавить запись», или «Удалить запись», и т.д.

8. История изменений. Так как нужно сохранять журнал, кто какие изменения вносил в сущности, то делаем historyService, и во всех create, update, remove в различных сервисах передаем в historyService, данные о ID изменяемой записи, и пользователе.

Чтобы не делать создание истории изменений для каждой сущности, сделан универсальный метод calcChangeAndCreate в historyService, в который передаем старый экземпляр сущности, и новый экземпляр сущности, и имя пользователя. На примере update для контакта:

async update(recordId: string, updateDto: ContactUpdateDTO, userMail: string): Promise<ContactEntity>  {
    const options = {
      where: [{ 'id': recordId }]
    }
    const oldResult = await this.contactRepository.findOne( options );
await this.contactRepository.update( { "id": recordId }, updateDto);
    const newResult = await this.contactRepository.findOne( options );

  await this.historyService.calcChangeAndCreate( 'contact', newResult, oldResult, userMail, recordId );

    return newResult;
}

Внутри метода calcChangeAndCreate сравниваем поля старого и нового объекта, и если есть изменения, создаем запись в истории изменений:

calcChange( newEntity: object, oldEntity: object ): HistoryRecordChange {
    let result = {} as HistoryRecordChange;
  result.fieldChangeList = []; // массив полей с изменениями, HistoryFieldChange
    result.haveChange = false; // По умолчанию изменений нет
    result.user = 'no_user';
    result.table = 'no_table';
    result.record = 'no_record';


    for( let key of Object.keys( newEntity) ) {
      if( key != 'created' && key != 'updated'  ) { // Пропустить авто поле
            // Тип дата из mysql возвращается как объект
              // а один объект не равен другому объекту
              let newValue: string;
              if(
                  typeof newEntity[key] == 'object'
                  &&
                  newEntity[key] != null
              ) {
                  newValue = newEntity[key].toISOString() + ' (UTC)';
              }
              else {
                  newValue = newEntity[key];
              }

              // Тип дата из mysql возвращается как объект
              // а один объект не равен другому объекту
              let oldValue: string;
              if(
                  typeof oldEntity[key] == 'object'
                  &&
                  oldEntity[key] != null
              ) {
                  oldValue = oldEntity[key].toISOString() + ' (UTC)';
              }
              else {
                  oldValue = oldEntity[key];
              }

        if( newValue != oldValue ) { // В этом поле есть расхождения
          result.haveChange = true;
              let fieldChange = {} as HistoryFieldChange;
              fieldChange.field = key;
              fieldChange.oldValue = oldValue;
              fieldChange.newValue = newValue;
              result.fieldChangeList.push( fieldChange );
        }
      }

return result;
}

Аналогично для создания сущности, для удаления сущности. Еще есть дочерние сущности, например номер телефона в списке телефонов контакта. Об изменениях дочерних сущностей пишем в историю родительской сущности.

Соответственно в карточке каждой сущности делаем вкладку «История изменений»:

Нужно найти время, и сделать репозиторий, с примером бека на NestJs с правами доступа, и удалением записей, и с историей изменений. Ставьте лайки и подписывайтесь на канал если интересно посмотреть новое видео про котят ))) репозиторий с примером такого бека.

А если уже есть такие репы на гите, просьба оставить ссылки. Но я что то не нашел на гите нормального бека на NestJs.

Комментарии (0)