
В первой части мы разобрались с основными принципами проектирования и разработки приложений для 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-admin const firebaseAdmin = require('firebase-admin'); // Инициализируем firebaseAdmin firebaseAdmin.initializeApp({ credential: firebaseAdmin.credential.applicationDefault(), databaseURL: 'https://<ID-проекта>.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://<ID-проекта>.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
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. Что было на хакатоне.

Это были насыщенные 2 дня.
Сначала были обучающие лекции, а во второй половине дня мы приступили к реализации своих проектов.
Следующий день велась активная доработка проектов и подготовка презентаций.
Ребята из Google все это время помогали нам и отвечали на кучу вопросов, которые неизбежно возникают в работе. Это была отличная возможность узнать много нового и оставить фидбек, пока железо еще горячо.
Спасибо всем участникам, организаторам из Google а также экспертам, которые вели лекции и помогали нам на протяжении хакатона!
Мы, кстати, заняли второе место.
Если появятся вопросы, можно написать:
shipa_o
raenardev
comradeguest
А также есть Telegram-чат посвященный обсуждению голосовых интерфейсов, заходите:
https://t.me/conversational_interfaces_ru
ссылка на оригинал статьи https://habr.com/company/redmadrobot/blog/420111/
Добавить комментарий