В конце прошлого месяца состоялся официальный релиз Google Ассистента на русском языке, так что самое время разобраться, как делать свои приложения (экшены) для Ассистента на стандартном технологическом стеке Google. В этой статье мы рассмотрим создание экшена в Actions on Google, разберём процесс извлечения сущностей и интентов из фраз в Dialogflow, узнаем, как писать обработчики извлеченной информации и работать с сетью в Cloud Functions for Firebase.
Рис. 1. Архитектура приложения для Ассистента.
Разработка под Ассистента начала активно развиваться сравнительно недавно, поэтому в сети пока мало материалов, а количество используемых инструментов и технологий существенно повышает порог вхождения. Эта статья хоть и не решает, но как минимум способствует решению упомянутых проблем. Начнем с архитектуры приложений для Ассистента (рис. 1), реализованных на стандартном технологическом стеке Google:
- Actions on Google — платформа для создания приложений для Google Ассистента.
- Dialogflow — NLU-движок (Natural Language Understanding), отвечающий за обработку естественных языков и дизайн диалогов.
- Cloud Functions for Firebase (для удобства будем использовать сокращение Firebase Functions) — облачные функции для обработки сложной логики взаимодействия с пользователем и для работы со сторонними сервисами. Firebase Functions и Dialogflow взаимодействуют через webhook, поэтому технически можно использовать любое другое серверное решение. Однако Firebase Functions является хорошей альтернативой, а иногда и заменой собственному backend’у. Он позволяет создавать и запускать сервисы на инфраструктуре Google, не заботясь о выделении, масштабировании или управлении серверами. С одной стороны, это позволяет сосредоточится на продуктовой составляющей разработки и функциональности сервиса, не тратя время на инфраструктурные задачи и администрирование. Но с другой стороны, как правило, делегирование влечет за собой ослабление контроля над ситуацией.
В статье делается акцент на технический аспект разработки, стоимость использования перечисленных сервисов разобрана не будет.
Рис. 2. Взаимодействие компонентов Google Ассистента (Основано на материале: Google Home and Google Assistant Workshop).
В рамках описанного стека логика работы экшена выглядит так (рис. 2):
- Пользователь обращается к приложению Google Ассистент и инициирует разговор с определенным экшеном.
- Google Ассистент через Actions on Google проксирует каждую фразу пользователя в текстовом формате в Dialogflow, дополнительно предоставляя информацию о самом пользователе (при предварительном запросе и с согласия пользователя) и текущей беседе.
- Dialogflow обрабатывает полученную фразу, извлекает из неё необходимую информацию и на основе ML принимает решения о том, какой ответ будет сформирован.
- В некоторых случаях Dialogflow может делегировать формирование ответа серверу на Firebase Functions, который, в свою очередь, может задействовать сторонние сервисы для получения необходимой для ответа информации.
- После того, как ответ сформирован, Dialogflow возвращает его в Actions on Google, откуда он поступает в приложение Google Ассистента.
Идея
Наш экшн будет по фразе определять, какие гифки хочет увидеть пользователь, а затем будет искать их через GIPHY API и возвращать пользователю в виде карточек. При реализации экшена мы разберем решение следующих задач:
- Настройка и связка Actions on Google, Dialogflow и Firebase Functions.
- Извлечение ключевых слов из фраз пользователя (Dialogflow).
- Создание сценариев диалога (Dialogflow).
- Работа с контекстом диалога (Dialogflow).
- Создание и подключение webhook для генерации ответа на фразу пользователя (Dialogflow, Firebase Function).
- Отображение карусели из карточек в интерфейсе (Firebase Functions).
- Загрузка информации из стороннего сервиса (Firebase Functions).
Первичная настройка
Рис. 3. Создание агента Dialogflow.
Прежде всего нам потребуется Google-аккаунт. Начнем с создания проекта в Dialogflow, для этого в консоли нажмем кнопку «Create Agent» и заполним необходимые поля (рис. 3):
- Язык по умолчанию: «Russian — ru».
- Часовой пояс: "(GMT+3:00) Europe/Moscow".
- Google Cloud Project: новый GCP для вашего Dialogflow-агента создастся автоматически, либо же вы можете выбрать один из существующих GCP-проектов, если таковые у вас имеются.
Затем нажимаем кнопку «Create» в правом верхнем углу и ждем, пока консоль конфигурирует новый проект.
Рис. 4. Стандартные интенты.
По умолчанию при создании агента Dialogflow создаются два интента (рис. 4):
- «Default Welcome Intent» — отвечает за приветствие пользователя;
- «Default Fallback Intent» — обрабатывает неизвестные фразы, которые Dialogflow не может отнести к каким-либо другим интентам.
Создание диалогов в Dialogflow уже было подробно описано в статьях тут, тут и тут, поэтому я не буду акцентировать внимание на его принципе работы.
Рис. 5. Ответы для «Default Welcome Intent».
Добавим в «Default Welcome Intent» несколько приветственных ответов, которые помогут пользователю понять, для чего нужен экшн и какие функции он умеет выполнять. В разделе «Responses» выберем вкладку «Google Assistant» и в «Suggestion Ships» пропишем примеры фраз, чтобы подсказать пользователю, как можно общаться с экшеном (рис. 5).
Экшн можно отлаживать в Google Ассистенте как на телефоне, так и в официальном эмуляторе. Чтобы открыть эмулятор, необходимо зайти в раздел «Integrations», в карточке «Google Assistant» нажать на кнопку «Integration Settings» и кликнуть на «Manage Assistant App». И в телефоне и в эмуляторе экшн можно запустить кодовой фразой «Окей Google, я хочу поговорить с моим тестовым приложением».
Базовый сценарий: поиск гифок
Создадим новый интент «Search Intent», который будет извлекать из фразы пользователя ключевые слова и передавать их по webhook серверу на Firebase Functions. Сервер, в свою очередь, с помощью GIPHY API найдет соответствующие гифки и вернет пользователю результат в виде карточек.
Рис. 6. Добавление тренировочных фраз.
Для начала в раздел «Training Phrases» добавим типовые фразы для обучения (рис. 6):
- «Я хочу посмотреть на танцующих жирафов».
- «Найди анимашки».
- «Покажи котиков».
- «Покажи гифки».
- «Найди мне анимированных слонов».
- «Покажи гифки с пандами».
- «Гифки с енотами-полоскунами».
- «У тебя есть тюлени».
- «Найди смешные падения».
Рис. 7. Извлечение параметров из текста.
У добавленных фраз отметим параметр поиска, который Dialogflow должен выделить из текста. В данном случае наиболее подходящим типом параметра будет @sys.any
, поскольку в качестве параметра поискового запроса может выступать практически любая языковая конструкция. Назовем этот параметр query
и отметим как обязательный (рис. 7).
Рис. 8. Перечень наводящих вопросов.
В подразделе «Prompts» пропишем уточняющие вопросы, которые Dialogflow будет задавать, если не сможет извлечь из фразы ключевые слова (рис. 8).
Далее следует спуститься в раздел «Fulfillment» в самом низу страницы (не путать с одноименным разделом в левом меню). нажать кнопку «Enable Fullfilment», а потом включить настройку «Enable webhook call for this intent». Это позволит Dialogflow при попадании в интент делегировать формирование ответа Firebase Functions.
Теперь перейдем во вкладку «Fulfillment» в левом меню и включим «Inline Editor», где пропишем логику для только что созданного «Search Intent». Для поиска гифок по ключевым словам мы будем использовать запрос https://api.giphy.com/v1/gifs/search, который возвращает список найденных объектов в JSON-формате согласно спецификации. Полученный от GIPHY ответ мы будем выводить в виде Browsing Carousel — карусель из карточек с изображениями, при нажатии на которые открывается веб-страница. В нашем случае при клике на карточку пользователь будет переходить на страницу сервиса GIPHY с этой анимацией и списком похожих.
Код, реализующий описанную выше функциональность, представлен ниже.
'use strict';
const GIPHY_API_KEY = 'API_KEY';
const SEARCH_RESULTS = [
'Хе-хе, сейчас покажу мои любимые.',
'Лови, отличная подборка гифок.',
'Смотри, что я нашел!'
];
// Import the Dialogflow module from the Actions on Google client library.
const { dialogflow, BrowseCarouselItem, BrowseCarousel, Suggestions, Image } = require('actions-on-google');
// Import the firebase-functions package for deployment.
const functions = require('firebase-functions');
// Import the request-promise package for network requests.
const request = require('request-promise');
// Instantiate the Dialogflow client.
const app = dialogflow({ debug: true });
function getCarouselItems(data) {
var carouselItems = [];
data.slice(0, 10).forEach(function (gif) {
carouselItems.push(new BrowseCarouselItem({
title: gif.title || gif.id,
url: gif.url,
image: new Image({
url: gif.images.downsized_medium.url,
alt: gif.title || gif.id
}),
}));
});
return carouselItems;
}
function search(conv, query) {
// Send the GET request to GIPHY API.
return request({
method: 'GET',
uri: 'https://api.giphy.com/v1/gifs/search',
qs: {
"api_key": GIPHY_API_KEY,
'q': query,
'limit': 10,
'offset': 0,
'lang': 'ru'
},
json: true,
resolveWithFullResponse: true,
}).then(function (responce) {
// Handle the API call success.
console.log(responce.statusCode + ': ' + responce.statusMessage);
console.log(JSON.stringify(responce.body));
// Obtain carousel items from the API call response.
var carouselItems = getCarouselItems(responce.body.data);
// Validate items count.
if (carouselItems.length <= 10 && carouselItems.length >= 2) {
conv.data.query = query;
conv.data.searchCount = conv.data.searchCount || 0;
conv.ask(SEARCH_RESULTS[conv.data.searchCount % SEARCH_RESULTS.length]);
conv.data.searchCount++;
conv.ask(new BrowseCarousel({ items: carouselItems }));
} else {
// Show alternative response.
conv.ask('Ничего не смог найти по такому запросу, может поищем что-то другое?)');
}
}).catch(function (error) {
// Handle the API call failure.
console.log(error);
conv.ask('Извини, кажется альбом с гифками потерялся.');
});
}
// Handle the Dialogflow intent named 'Search Intent'.
// The intent collects a parameter named 'query'.
app.intent('Search Intent', (conv, { query }) => {
return search(conv, query);
});
// Set the DialogflowApp object to handle the HTTPS POST request.
exports.dialogflowFirebaseFulfillment = functions.https.onRequest(app);
{
"name": "dialogflowFirebaseFulfillment",
"description": "This is the default fulfillment for a Dialogflow agents using Cloud Functions for Firebase",
"version": "0.0.1",
"private": true,
"license": "Apache Version 2.0",
"author": "Google Inc.",
"engines": {
"node": "~6.0"
},
"scripts": {
"start": "firebase serve --only functions:dialogflowFirebaseFulfillment",
"deploy": "firebase deploy --only functions:dialogflowFirebaseFulfillment"
},
"dependencies": {
"actions-on-google": "2.0.0-alpha.4",
"firebase-admin": "^4.2.1",
"firebase-functions": "^0.5.7",
"dialogflow": "^0.1.0",
"dialogflow-fulfillment": "0.3.0-beta.3",
"request": "^2.81.0",
"request-promise": "^4.2.1"
}
}
Поскольку пользователь может обращаться несколько раз к одному и тому же интенту, рекомендуется возвращать ему разнообразные ответы. Для этого был использован JSON-объект Conversation.data
, сохраняющий свое значение как при повторном обращении к интенту, так и при обращении к другим сценариям разговора.
Рис. 9. Инициализация беседы (слева), уточнение параметров поиска и дальнейшее отображение результатов (по центру), отображение поисковой выдачи для нового запроса (справа)
Примечание: для работы с API сторонних сервисов через Firebase Functions необходимо подключить биллинг, иначе при попытках работы с сетью будет возникать ошибка:
«Billing account not configured. External network is not accessible and quotas are severely limited. Configure billing account to remove these restrictions».
Для этого в левом меню следует кликнуть на «Платный аккаунт» и среди предложенных тарифных планов выбрать Flame ($25 в месяц) либо Blaze (оплата по мере использования). Я выбрал последний вариант, поскольку в рамках разработки тестового приложения он показался мне более выгодным.
Продвинутый сценарий: пагинация
В большинстве случаев по поисковому запросу GIPHY найдет значительно больше десяти гифок, поэтому правильно будет позволить пользователю увидеть всю поисковую выдачу, т.е. добавить пагинацию.
В консоли Dialogflow наведем курсор на ячейку «Search Intent». Справа появятся несколько кнопок, нажмем на «Add follow-up intent». Это позволит нам создать ветвь разговора, следующую после «Search Intent». Среди элементов выпадающего списка выберем «more» — стандартный игнтент для инициирования отображения дополнительной информации.
Рис. 10. Контекст интента «Search Intent — more».
Перейдем в только что созданный интент и внесем изменения в раздел «Context». Поскольку пользователь может несколько раз подряд просить показать ещё гифок, этот интент должен уметь вызываться рекурсивно. Для этого в исходящем контексте необходимо прописать ту же строку, что указана во входящем (рис. 10). В разделе «Fullfilment» также следует включить настройку «Enable webhook call for this intent».
Теперь вернемся в «Fillfulment» из бокового меню, где инициализируем обработчик для «Search Intent — more». Также добавим в функцию search
параметр offset
, который будет использоваться при пагинации в GIPHY API.
const SEARCH_RESULTS_MORE = [
'Вот ещё пара гифок!',
'Надеюсь, эти тебе тоже понравятся.',
'На, лови еще парочку. Если что, у меня ещё есть.'
];
function search(conv, query, offset) {
// Send the GET request to GIPHY API.
return request({
method: 'GET',
uri: 'https://api.giphy.com/v1/gifs/search',
qs: {
"api_key": GIPHY_API_KEY,
'q': query,
'limit': 10,
'offset': offset,
'lang': 'ru'
},
json: true,
resolveWithFullResponse: true,
}).then(function (responce) {
// Handle the API call success.
console.log(responce.statusCode + ': ' + responce.statusMessage);
console.log(JSON.stringify(responce.body));
// Obtain carousel items from the API call response.
var carouselItems = getCarouselItems(responce.body.data);
// Validate items count.
if (carouselItems.length <= 10 && carouselItems.length >= 2) {
conv.data.query = query;
conv.data.offset = responce.body.pagination.count + responce.body.pagination.offset;
conv.data.paginationCount = conv.data.paginationCount || 0;
conv.data.searchCount = conv.data.searchCount || 0;
// Show successful response.
if (offset == 0) {
conv.ask(SEARCH_RESULTS[conv.data.searchCount % SEARCH_RESULTS.length]);
conv.data.searchCount++;
} else {
conv.ask(SEARCH_RESULTS_MORE[conv.data.paginationCount % SEARCH_RESULTS_MORE.length]);
conv.data.paginationCount++;
}
conv.ask(new BrowseCarousel({ items: carouselItems }));
conv.ask(new Suggestions(`Ещё`));
} else {
// Show alternative response.
conv.ask('Ничего не смог найти по такому запросу, может поищем что-то другое?)');
}
}).catch(function (error) {
// Handle the API call failure.
console.log(error);
conv.ask('Извини, кажется альбом с гифками потерялся.');
});
}
// Handle the Dialogflow intent named 'Search Intent - more'.
app.intent('Search Intent - more', (conv) => {
// Load more gifs from the privious search query
return search(conv, conv.data.query, conv.data.offset);
});
Рис. 11. Пагинация при поиске гифок.
Результат
Видео работы экшена представлено ниже.
Код проекта и дамп ассистента доступен на Github.
- Перейдите в консоль Dialogflow и создайте нового агента или выберите существующего.
- Кликните на иконке настроек, перейдите в раздел «Export and Import» и нажмите кнопку «Restore from ZIP». Выберите ZIP-файл из корневой директории репозитория.
- Выберите «Fulfillment» из левого навигационного меню.
- Включите настройку «Inline Editor».
- Скопируйте содержимое файлов из директории
functions
в соответствующие вкладки в «Fulfillment». - Укажите ваш ключ доступа к GIPHY API во вкладке index.js.
- Перейдите в консоль Firebase и смените ваш тарифный план на Flame или Blaze. Работа со сторонними сервисами по сети недоступна при бесплатном тарифном плане.
Автор: sismetanin