From zero to “Actions on Google” hero: ваш код
В первой части мы разобрались с основными принципами проектирования и разработки приложений для 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://.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
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