Привет, Хабр!

Время от времени я возвращаюсь к своему pet-проекту голосового ассистента с кодовым именем «Альфа», который разрабатывался как приватный голосовой интерфейс (а-ля «умная колонка») для управления своим «Умным домом». И в этот раз — так сошлись звезды или под влиянием магнитных бурь — мне очень захотелось добавить новый навык. А что из этого вышло, читайте далее.

Друзья, чтобы понимать, что тут вообще происходит, рекомендую ознакомиться с моими ранними статьями: «Моя б̶е̶з̶умная колонка или бюджетный DIY голосового ассистента для умного дома» — тыц и «Моя б̶е̶з̶умная колонка: часть вторая // программная» — тыц. В статьях описана аппаратная и базовая программная реализация «Альфы». Спасибо!

❯ М̶о̶и̶ ̶х̶о̶т̶е̶л̶к̶и̶ Техническое задание

Прежде чем продолжить, давайте вспомним что такое навык. Навык (Skill) — это сторонняя программа, которая подключается к голосовому ассистенту (через специальный API или модуль) и расширяет его функционал. Она активируется определенной фразой-триггером.

В моем случае мне необходимо реализовать функционал навыка, который бы обеспечивал запись планируемых событий с помощью голоса в какой-нибудь локальный или удаленный сервис планировщика для возможности синхронизации задач с устройствами пользователя (например, для просмотра событий на смартфоне пользователя). Касательно последнего, то проще всего для этих целей интегрировать Google календарь или аналогичные сервисы. И для реализации данного навыка нам потребуется сделать следующее:

  • Добавить в словарь команды: для активации навыка и вывода планируемых событий;

  • Разработать логику активации навыка и ввода голосовых данных;

  • Разработать метод извлечения параметров (название события, дату и время) из голосовых данных (после транскрибации);

  • Разработать модуль взаимодействия с ��нешним сервисом (Google календарь);

  • Разработать метод вывода сохраненных задач с помощью голосового оповещения (синтеза речи).

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

Блок-схема работы навыка
Блок-схема работы навыка

❯ Команды активации и действия

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

command_dic = {
  #... предыдущие команды
  # Новые команды
  "schedule_list": ('скажи задачи на сегодня', 'какой список задач сегодня', 'список дел на сегодня', 'список дел', 'какие задачи сегодня'),
  "schedule_add": ('добавь задачу', 'добавь напоминание','создай событие', 'добавь событие')
        }

Как вы уже наверное смогли догадаться, ключ команды schedule_list — отвечает за вывод списка задач, а schedule_add — за добавление задачи. Теперь осталось только добавить в обработчик команд данные ключи:

def command_processing(key: str):
    match key:
        # ... Предыдущие команды
        case 'schedule_list':   # Активация команды schedule_list
            shedule_list()     
        case 'schedule_add':    # Активация команды schedule_add 
            # Здесь будет какой-то код
        case _:
            print('Нет данных')

Изначально у нас реализована следующая функция для распознания имени и обработки команд:

def response(voice: str):
    if glob_var.read_bool_wake_up():                  # этап второй, распознавание команды
        command_processing(recognize_command(voice))  # распознавание и выполнение команды
        glob_var.set_bool_wake_up(False)              # после выполнения команды, перехохим в режим распознования имени
    glob_var.set_bool_wake_up(name_recognize(voice))  # проверяем наличие имени в потоке
    if glob_var.read_bool_wake_up():                  # если имя обнаружено, воспроизводим звуковой сигнал
        tts.play_wakeup_sound('notification.wav')

И так как нам нужна повторная активация ассистента (исключая функцию распознания имени) при активации навыка, то внесем небольшие изменения в выше указанный код:

stat = False # Глобальная переменнаая, статус активации навыка

def response(voice: str):
    global stat 
    
    if glob_var.read_bool_wake_up():                  # этап второй, распознавание команды
        stat = command_processing(recognize_command(voice), voice)  # распознавание и выполнение команды с получением булевого значения от функции
        glob_var.set_bool_wake_up(stat)              # после выполнения команды, перехохим в режим распознования имени
    
    if not stat:                                     # если навык активирован, то не проверяем наличие имени в потоке
      glob_var.set_bool_wake_up(name_recognize(voice))  # проверяем наличие имени в потоке
    
    if glob_var.read_bool_wake_up():                  # если имя обнаружено, воспроизводим звуковой сигнал
        tts.play_wakeup_sound('notification.wav')

И, соответственно, чтобы функция обработка команд (command_processing) смогла нам возвращать булево значение, изменим её код:

def command_processing(key: str, voice: str):
    global stat
    result = False
    match key:
        # ... Предыдущие команды
        case 'schedule_list':   # Активация команды schedule_list
            result = schedule_list()     
        case 'schedule_add':    # Активация команды schedule_add 
            tts.speak("Хорошо, назовите имя события и время для добавления.")
            result = True 
        case _:
            if stat:
              result = schedule_add(voice) # Пытаемся извлечь данные из строки
            else:
              print('Нет данных')
    return result               # Возврат статуса функций планировщика 

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

❯ Интеграция Google календаря

Интеграция сервиса «Google календарь» достаточно простая и не представляет каких либо сложностей, у Гугла хорошие мануалы. Описывать полный процесс я не буду, так как это минимум на еще одну статью.

Чтобы интегрировать сервис в наш проект, нужно установить необходимые пакеты. Давайте же их скорее установим, команду для установки пакетов вы можете обнаружить ниже:

pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib

Для взаимодействия с Google календарем, нам необходимо получить креды (credentials). Для сервиcного аккаунта (получается при создании приложения в панели Google Cloud) и для пользователя. Если первое — это проблема разработчика, то второе — на совести пользователя.

Так как «Альфа» по большей части имеет модульную структуру, то и взаимодействие с Google календарем будет реализовано в отдельном модуле. Ниже приведен код для работы с Google календарем (calendar_schedule.py):

calendar_schedule.py
import datetime
import os.path

from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

SCOPES = ['https://www.googleapis.com/auth/calendar']
calendarId = "primary" # Им�� календаря пользователя


class GoogleCalendar():

    def __init__(self):
         creds = None
        if os.path.exists("token.json"):
            creds = Credentials.from_authorized_user_file("token.json", SCOPES)
        try:
            self.service = build("calendar", "v3", credentials=creds)
        except HttpError as error:
            print(f"An error occurred: {error}")


    # создание события в календаре
    def create_event(self, summary, start_time, end_time, description=None):
        """Создает новое событие в Google Календаре."""
        try:
            # создание словаря с информацией о событии
            event_body = {
                'summary': summary,
                'start': {'dateTime': start_time.format(), 'timeZone': 'Asia/Yekaterinburg'},  # Укажите ваш часовой пояс
                'end': {'dateTime': end_time.format(), 'timeZone': 'Asia/Yekaterinburg'},
                'description': description,
            }

            event = self.service.events().insert(calendarId=calendarId, body=event_body).execute()
            print(f'Событие создано: {event.get("htmlLink")}')
            return f'Событие {summary} добавлено в ваш календарь'

        except HttpError as error:
            print(f'Произошла нелепая ошибка: {error}')
            return f'Сожалею, но произошла ошибка при создании события. Попробуйте позже.'

    # вывод списка предстоящих событий
    def get_events_list(self):
        now_dts = datetime.datetime.now(tz=datetime.timezone.utc)
        now = datetime.datetime.now(tz=datetime.timezone.utc).isoformat()  # Начальная дата(сейчас)
        ends = (now_dts + datetime.timedelta(days=1)).isoformat()          # Конечная дата +1 день
        print('Получение списка следующих событий')
        events_result = self.service.events().list(calendarId=calendarId,
                                                   timeMin=now,
                                                   timeMax=ends,
                                                   maxResults=10, singleEvents=True,
                                                   orderBy='startTime').execute()
        events = events_result.get('items', [])
        return events

В модуле реализован отдельный класс GoogleCalendar() и методы create_event(), get_events_list() которые мы будем использовать для создания и получения событий.

Для работы модуля необходимо получить токен авторизации, который содержится в файле token.json. Для получения файла можно воспользоваться следующим скриптом (json_user.py):

json_user.py
import os.path

from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

SCOPES = ['https://www.googleapis.com/auth/calendar']
calendarId = "primary"  # Имя календаря пользователя


def get_user_cred():
    creds = None
    if os.path.exists("token.json"):
        creds = Credentials.from_authorized_user_file("token.json", SCOPES)
    # If there are no (valid) credentials available, let the user log in.
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(
                "credentials.json", SCOPES
            )
            creds = flow.run_local_server(port=0)
        # Save the credentials for the next run
        with open("token.json", "w") as token:
            token.write(creds.to_json())
    try:
        build("calendar", "v3", credentials=creds)
    except HttpError as error:
        print(f"An error occurred: {error}")


if __name__ == "__main__":
    get_user_cred()

Файл credentials.json — это токен сервисного аккаунта, как я говорил ранее, он загружается из панели разработчика Google Cloud. Само собой, хранить токены в файлах — это не лучшая практика, но на данный момент и так сойдёт.

Запуск скрипта для получения файла выполняется с помощью команды:

python3 json_user.py

После запуска скрипта автоматически откроется браузер, где будет предложено выполнить вход с помощью аккаунта Google:

Вход с помощью аккаунта Google
Вход с помощью аккаунта Google

И заодно можно посмотреть предоставляемые разрешения:

❯ Извлекаем данные из фразы

Вот мы и добрались до самого интересного, а именно до извлечения данных для записи события из произнесенной фразы. На первый взгляд всё выглядит просто, но на самом деле — нет. Конечно, мы живем во времена искусственного интеллекта, «запусти LLM, «скорми» фразу и получи ответ в нужном формате» — скажите вы, да но тут несколько нюансов:

  • «Альфа» должна обрабатывать все запросы локально, чтобы обеспечивать приватность, быстродействие и независимость от внешних сервисов;

  • Локальное использование LLM требует больших вычислительных ресурсов, в том числе с применением NPU. «Альфа» работает на бюджетном железе, что ограничивает локальный запуск LLM;

  • Локальное применение LLM не обеспечит необходимого быстродействия.

Возможно я ошибаюсь, поправьте меня в комментариях, также буду рад вашим советам.

Учитывая всё вышесказанное, и ради быстродействия, будем использовать классический метод — парсинг с помощью регулярных выражений и стандартного Python-модуля re.

Работа с регулярными выражениями почему-то вызывает у меня дикую боль, поэтому делегируем эту боль задачу DeepSeek'у. И спустя несколько часов общения, мы получили более-менее рабочий код нашего парсера:

reminder_parser.py

Парсер возвращает необходимые нам данные в формате JSON. Для теста можно использовать следующий код:

Тест парсера
# Тестирование парсера
def test_fixed_parser():
    parser = SmartReminderParser()
    
    test_cases = [
        # Фразы для теста
        "встреча в десять сорок пять", 
        "позвонить маме в восемь ноль пять",
        "Позвонить в двадцать пять минут девятого",
        "Встреча в сорок пять минут второго",
        "Встреча в среду в десять сорок пять",
        "Сходить к врачу завтра в четырнадцать сорок пять",
        "Подготовить документы сегодня в двадцать три часа сорок пять минут",
        "оплатить счета сегодня в шестнадцать ноль ноль",
        "записаться к врачу сегодня в десять сорок пять",
        "принять лекарство в восемь утра и восемь вечера",
        "сходить в магазин в пятнадцать тридцать",
        "подготовить отчет в девятнадцать двадцать",
        "Сдать отчет в понедельник в пятнадцать тридцать",
        "Купить продукты сегодня в восемнадцать тридцать",
    ]
    
    print("Тестирование исправленного парсера с составными числами:")
    print("=" * 70)
    
    successful = 0
    for phrase in test_cases:
        result = parser.parse(phrase)
        if result:
            print(f"✓ '{phrase}'")
            print(f"  Действие: '{result['text']}'")
            print(f"  Время: {result['time'].strftime('%d.%m.%Y %H:%M')}")
            print(f"  Тип: {result['time_type']}")
            print()
            successful += 1
        else:
            print(f"✗ '{phrase}' -> не распознано")
            # Диагностика
            text = parser._preprocess_text(phrase.lower())
            print(f"  После предобработки: '{text}'")
            print()
    
    print(f"Успешно распознано: {successful}/{len(test_cases)}")
    
    # Тест составных чисел
    print("\n" + "=" * 70)
    print("Тест составных числительных:")
    print("=" * 70)
    
    composite_tests = [
        "двадцать пять", "сорок пять", "пятьдесят пять", 
        "двадцать один", "тридцать восемь", "сорок два"
    ]
    
    for test in composite_tests:
        result = parser._word_to_num(test.replace(' ', '_'))
        print(f"'{test}' -> {result}")


if __name__ == "__main__":
    test_fixed_parser()

Ниже видео живого теста парсера:

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

❯ Финальная интеграция

Если вы дочитали до этого момента, то поздравляю, мы близки к финалу :). Итак, давайте для понимания резюмируем то, что мы сделали выше. Мы написали два программных модуля, которые отвечают за работу с сервисом Google календарь (методы create_event(), get_events_list()) и за извлечение данных (парсинг) из произнесенной фразы (метод smart_parcer()). Теперь дело за малым – интегрировать наши программные модули в основной скрипт умной колонки.

Импортируем наши модули в основной скрипт:

import calendar_schedule
import reminder_parser

И для проверки наличия авторизации в Google календаре, добавим следующий код:

errors_alarm = "" # Переменная для хранения ошибок для последкющего озвучивания

try:
	schedule = calendar_schedule.GoogleCalendar() # Пытаемся инициировать класс работы с календарём
except:
    print(f"Ошибка подключения календаря Google.")
    errors_alarm = "Ошибка подключения календаря."

И озвучиваем ошибки при запуске системы:

if errors_alarm:
   tts.speak('При запуске системы возникли следующие ошибки')
   tts.speak(errors_alarm)

И теперь нам осталось добавить в основной скрипт функции записи события в календарь – schedule_list() и вывода списка событий – schedule_add(), которые вызываются в command_processing():

def schedule_add(phrase: str):
    # Создаем экземпляр парсера
    parser = reminder_parser.SmartReminderParser()
    result = parser.parse(phrase)
    if result:
        print(f"? Фраза: {result['original']}")
        print(f"⏰ Время: {result['time']}")
        print(f"? Действие: '{result['text']}'")
        print(f"? Тип: {result['time_type']}")
        print(f"? Timestamp: {result['timestamp']}")
        print("-" * 70)
        dt = result['time']
        start = dt.isoformat()    # Преобразуем в формат времени 
        end = (dt + datetime.timedelta(hours=1)).isoformat() # Будем считать, что событите будет длиться час
        descr = "? Задание отправлено с умной колонки"
        event = result['text']
        try:
           text = schedule.create_event(event.capitalize(), start, end, description=descr) # Создаем событие в календаре
           tts.speak(text) # Говорим, что событие успешно создано
           return False
        except:
            tts.speak("Извините, возникла ошибка записи события в календарь. Попробуйте еще раз.")
            return True
    else:
       tts.speak("Я не смогла распознать событие, пожалуйста, попробуйте еще раз.")
       return True

Где метод create_event() отвечает за создание события в Google календаре. Результат работы данной функции можно также наблюдать в терминале в процессе отладки:

Вывод в терминал
Вывод в терминал

Ниже на видео представлена работа функции добавления события в календарь:

После этого мы можем видеть наше событие в Google календаре:

Событие в Google календаре
Событие в Google календаре

И функция озвучивания предстоящих событий:

def schedule_list():
    """Озвучиваем список предстоящих событий"""
    try:
        events = schedule.get_events_list()
        if not events:
            print('Нет предстоящих событий.')
            tts.speak("Нет предстоящих событий.")
        else:
            print(events)
            tts.speak("Нашла следующие события.")

            for even in events:
                tts.speak(" " + even['summary'])
                start = even['start'].get('dateTime', even['start'].get('date'))
                print(str(start))
                dt = datetime.datetime.fromisoformat(start)
                text = "Запланировано на"
                male_units = ((u'час', u'часа', u'часов'), 'm')
                text += num2words_ru.num2text(dt.hour, male_units) + '.'
                male_units = ((u'минута', u'минуты', u'минут'), 'f')
                text += num2words_ru.num2text(dt.minute, male_units) + '.'
                tts.speak(text)
    except:
        tts.speak("Сожалею, но возникла ошибка, попробуйте позже!")

Ниже на видео вы можете наблюдать работу данной функции:

❯ Итоги

Пока на этом можно и закончить статью. Спасибо, что дочитали :). «А где оповещение умной колонки о наступающих событиях?» – скажете вы, да оно есть, но это уже контент для следующей статьи.

Если у вас есть вопросы, пожелания или советы – добро пожаловать в комментарии! Интересных проектов и спасибо за внимание!

Ссылки к статье:

  1. Моя б̶е̶з̶умная колонка или бюджетный DIY голосового ассистента для умного дома;

  2. Моя б̶е̶з̶умная колонка: часть вторая // программная;

  3. Исходный код парсера.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud - в нашем Telegram-канале 

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