From zero to “Actions on Google” hero: ваш код

image

В первой части мы разобрались с основными принципами проектирования и разработки приложений для Google Assistant. Теперь пришло время написать собственного помощника, чтобы пользователи могли наконец выбрать фильм на вечер. Разработчики shipa_o, raenardev и дизайнер ComradeGuest продолжают рассказывать.


Пишем свой код

Давайте попробуем написать что-то посложнее.
Допустим наш агент рекомендует фильмы по жанрам.
Мы просим его «Покажи ужастик», и агент будет парсить жанр, искать фильм в коллекции по жанру и выводить его на экран.


Для начала будем хранить коллекцию фильмов в переменной:
var json = {
    "filmsList": [
        {
            "id": "1",
            "title": "Властелин колец: Братство кольца",
            "description": "Описание фильма Властелин колец",
            "genres": ["фэнтези", "драма", "приключения"],
            "imageUrl": "http://t3.gstatic.com/images?q=tbn:ANd9GcQEA5a7K9k9ajHIu4Z5AqZr7Y8P7Fgvd4txmQpDrlQY2047coRk",
            "trailer": "https://www.youtube.com/watch?v=RNksw9VU2BQ"
        },
        {
            "id": "2",
            "title": "Звёздные войны: Эпизод 2 – Атака клонов",
            "description": "Описание фильма Звёздные войны",
            "genres": ["фантастика", "фэнтези", "боевик", "приключения"],
            "imageUrl": "http://t3.gstatic.com/images?q=tbn:ANd9GcTPPAiysdP0Sra8XcIhska4MOq86IaDS_MnEmm6H7vQCaSRwahQ",
            "trailer": "https://www.youtube.com/watch?v=vX_2QRHEl34"
        },
 {
            "id": "3",
            "title": "Чужой",
            "description": "Описание фильма Чужой",
            "genres": ["ужасы", "фантастика", "триллер"],
            "imageUrl": "https://www.kinopoisk.ru/images/film_big/386.jpg",
            "trailer": "https://www.youtube.com/watch?v=xIe98nyo3xI"
        }
    ]
};
exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
    const agent = new WebhookClient({ request, response });

    let result = request.body.queryResult;
    let parameters = result.parameters;
    let outputContexts = result.outputContexts;

    let intentMap = new Map();
    // Передаем в функцию agent и parameters, для того чтобы достать оттуда жанр
    intentMap.set('search-by-genre', searchByGenre.bind(this, agent, parameters));
    agent.handleRequest(intentMap);
});

function searchByGenre(agent, parameters) {
    let filmsList = json.filmsList;

    // Фильтруем фильмы по жанру
    let filteredFilms = filmsList.filter((film) => {
        // Проверяем, что выбранный жанр совпадает хотя бы с одним из указанных для фильма
        return film.genres.some((genre) => genre == parameters.genre);
    });

    // Берем первый фильм в нужном жанре
    let firstFlim = filteredFilms[0];

    // Выводим название фильма
    agent.add(firstFlim.title);

    // Выводим карточку с информацией о фильме
    agent.add(new Card({
 title: firstFlim.title,
        imageUrl: firstFlim.imageUrl,
        text: firstFlim.description,
        buttonText: 'Посмотреть трейлер',
        buttonUrl: firstFlim.trailer
    }));

    // Подталкиваем пользователя к дальнейшим действиям
    agent.add([
        "Такой фильм тебе по душе?",
        new Suggestion("О чем он?"),
        new Suggestion("Да"),
        new Suggestion("Нет")
    ]);
}

Теперь ответ стал информативнее.
Мы выводим текст, карточку с информацией и подсказки:


Вывод информации о фильме

Хорошая особенность Dialogflow в том, что он из коробки адаптирован под разные устройства.
Если у устройства будут динамики, то все фразы, которые мы отправляем в метод add, будут озвучены, а если не будет экрана, то объекты Card и Suggestion просто не будут отображаться.


Подключаем базу данных

Давайте усложним задачу и добавим получение данных из базы данных (БД).
Самый простой способ — это использование firebase realtime database.
Для примера будем использовать Admin Database API.

Сначала нужно создать базу данных и заполнить её.
Сделать это можно в том же проекте, который был создан для Cloud Functions:


Заполненая firebase realtime database

После того, как БД заполнена, подключим её к fulfillment:
// Импортируем зависимость firebase-admin
const firebaseAdmin = require('firebase-admin');

// Инициализируем firebaseAdmin
firebaseAdmin.initializeApp({
    credential: firebaseAdmin.credential.applicationDefault(),
    databaseURL: 'https://.firebaseio.com'
});

// Добавим функцию, которая будет обращаться к БД
function getFilmsList() {
    return firebaseAdmin
        .database()
        .ref()
        .child('filmsList')
        .once('value')
        .then(snapshot => {
            const filmsList = snapshot.val();
            console.log('filmsList: ' + JSON.stringify(filmsList));
            return filmsList;
        })
        .catch(error => {
            console.log('getFilmsList error: ' + error);
            return error;
        });
}

Обращение к БД требует многопоточности. API firebase database расчитано на использование Promise. Метод .once('value') возвращает нам Promise. Затем мы получаем наши данные в блоке then() и возвращаем Promise c ними, как результат выполнения функции.
Важно вернуть этот Promise в метод handleRequest(), иначе агент завершит работу с нашим callback без ожидания ответа и обработки результата.


Версия поиска фильма по жанру с использованием БД:
'use strict';

const functions = require('firebase-functions');
const firebaseAdmin = require('firebase-admin');
const { WebhookClient } = require('dialogflow-fulfillment');
const { Card, Suggestion } = require('dialogflow-fulfillment');

firebaseAdmin.initializeApp({
credential: firebaseAdmin.credential.applicationDefault(),
    databaseURL: 'https://.firebaseio.com'
});

exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
    const agent = new WebhookClient({ request, response });

    let result = request.body.queryResult;
    let parameters = result.parameters;
    let outputContexts = result.outputContexts;

    let intentMap = new Map();
    intentMap.set('search-by-genre', searchByGenre.bind(this, agent, parameters));
    agent.handleRequest(intentMap);
});

function getFilmsList() {
    return firebaseAdmin
        .database()
        .ref()
        .child('filmsList')
        .once('value')
        .then(snapshot => {
            const filmsList = snapshot.val();
            console.log('filmsList: ' + JSON.stringify(filmsList));
            return filmsList;
        })
        .catch(error => {
            console.log('getFilmsList error: ' + error);
            return error;
        });
}

function searchByGenre(agent, parameters) {
    return getFilmsList()
        .then(filmsList => {

            let filteredFilms = filmsList.filter((film) => {
                return film.genres.some((genre) => genre == parameters.genre);
            });

            let firstFlim = filteredFilms[0];

            agent.add(firstFlim.title);
            agent.add(new Card({
                title: firstFlim.title,
imageUrl: firstFlim.imageUrl,
                text: firstFlim.description,
                buttonText: 'Посмотреть трейлер',
                buttonUrl: firstFlim.trailer
            }));
            agent.add([
                "Такой фильм тебе по душе?",
                new Suggestion("О чем он?"),
                new Suggestion("Да"),
                new Suggestion("Нет")
            ]);
        })
        .catch(error => {
            console.log('getFilmsList error' + error);
        });
}


Добавляем непредсказуемости

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

Исправим это с помощью библиотеки перемешивания массивов shuffle-array.

Добавим зависимость в файл package.json:

"dependencies": {
    // ...
    "shuffle-array": "^1.0.1"
    // ...
}


Добавим перемешивание массива:
// Импортируем библиотеку
const shuffle = require('shuffle-array');

function searchByGenre(agent, parameters) {
    return getFilmsList()
.then(filmsList => {

            let filteredFilms = filmsList.filter((film) => {
                return film.genres.some((genre) => genre == parameters.genre);
            });

            // Перемешиваем массив фильмов
            shuffle(filteredFilms);
            let firstFlim = filteredFilms[0];

            agent.add(firstFlim.title);
            agent.add(new Card({
                title: firstFlim.title,
                imageUrl: firstFlim.imageUrl,
                text: firstFlim.description,
                buttonText: 'Посмотреть трейлер',
                buttonUrl: firstFlim.trailer
            }));
            agent.add([
                "Такой фильм тебе по душе?",
                new Suggestion("О чем он?"),
                new Suggestion("Да"),
                new Suggestion("Нет")
            ]);
        })
        .catch(error => {
            console.log('getFilmsList error' + error);
        });
}

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


Работаем с контекстом

Мы просим агента:


Покажи фэнтези

Агент отображает нам фильм «Властелин колец».
Затем мы спрашиваем:


О чем он?

Люди не говорят: «О чем фильм «Властелин Колец» — это неестественно. Поэтому нам надо сохранить информацию об отображаемом фильме. Сделать это можно в контексте:

// Запоминаем фильм в контексте, чтобы можно было ссылаться на него
agent.setContext({
    name: 'current-film',
    lifespan: 5,
    parameters: { id: firstFlim.id }
});


Тогда прочитать информацию о фильме мы сможем так:
function genreSearchDescription(agent) {
    // Получаем контекст current-film
    const context = agent.getContext('current-film');
    console.log('context current-film: ' + JSON.stringify(context));
    // Получаем id последнего отображаемого фильма
    const currentFilmId = context.parameters.id;

    // Получаем список фильмов
    return getFilmsList()
        .then(filmsList => {
            // Ищем фильм по id
            const currentFilm = filmsList.filter(film => film.id === currentFilmId);

            agent.add(currentFilm[0].description);
            agent.add([
                'Что скажешь?',
                new Suggestion('Интересно'),
                new Suggestion('Не нравится')
            ]);
        })
        .catch(error => {
            console.log('getFilmsList error:' + error);
        });
}

Тем же способом мы можем отфильтровать список уже показанных фильмов.


Интеграция с Telegram

Документация и полезные ссылки:

Для интеграции с Telegram практически ничего не требуется, но есть несколько особенностей, которые нужно учитывать.

1) Если в fullfilment сделать отображение Card или Suggestion, то в Telegram они тоже будут работать.
Но есть один баг: для quick replies необходимо указывать title, иначе в Telegram будет отображаться «Choose an item».
Решить проблему с указанием title в fullfilment у нас пока не получилось.

2) Если в intent-е используется Suggestion Chips для google assistant


Пример

то такой же функционал для Telegram можно реализовать двумя способами:

Quick replies


Настройка Quick replies

Custom payload
Тут можно реализовать быстрые ответы c помощью основной клавиатуры:


Скриншот основной клавиатуры

    {
        "telegram": {
            "text": "Выбери из списка или введи свой вариант:",
            "reply_markup": {
                "keyboard": [
                    [
                        "фантастика",
                        "приключения",
                        "комедия",
                        "драма",
                        "ужасы"
                    ]
                ],
                "one_time_keyboard": true,
                "resize_keyboard": true
       }
        }
    }

и встроенной клавиатуры:


Скриншот встроенной клавиатуры

    {
        "telegram": {
            "text": "Выбери из списка или введи свой вариант:",
            "reply_markup": {
                "inline_keyboard": [
                    [{
                        "text": "фантастика",
                        "callback_data": "фантастика"
                    }],
                    [{
                        "text": "приключения",
                        "callback_data": "приключения"
                    }],
                    [{
                        "text": "комедия",
                        "callback_data": "комедия"
                    }],
                    [{
                        "text": "драма",
                        "callback_data": "драма"
                    }],
                    [{
                        "text": "ужасы",
                        "callback_data": "ужасы"
                    }]
                ]
            }
        }
    }

Основная клавиатура будет отправлять сообщение, которое сохранится в истории, в то время как встроенная клавиатура этого не делает.

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

3) Если нужна разная логика для Telegram и Google ассистента, сделать это можно так:

let intentRequest = request.body.originalDetectIntentRequest;

if(intentRequest.source == 'google'){
    let conv = agent.conv();
    conv.ask('Такой фильм тебе по душе?');
    agent.add(conv);
} else {
    agent.add('Такой фильм тебе по душе?');
}

4) Отправку аудиофайла можно реализовать так:

{
  "telegram": {
    "text": "https://s0.vocaroo.com/media/download_temp/Vocaroo_s0bXjLT1pSXK.mp3"
  }
}

5) Контекст в Dialogflow будет храниться 20 минут. Нужно учитывать это при проектировании Telegram-бота. Если пользователь отвлекся на 20 минут, то он не сможет продолжить с того же места.


Примеры

Мы опубликуем исходный код навыка в ближайшее время. Сразу после его релиза.


PS. Что было на хакатоне.

image

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

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

Спасибо всем участникам, организаторам из Google, а также экспертам, которые вели лекции и помогали нам на протяжении хакатона!

Мы, кстати, заняли второе место.

Если появятся вопросы, можно написать:
shipa_o
raenardev
comradeguest

А также есть Telegram-чат посвященный обсуждению голосовых интерфейсов, заходите:
https://t.me/conversational_interfaces_ru

© Habrahabr.ru