Сценарии для виртуальных ассистентов Салют на NodeJS и фреймворке SaluteJS
Привет! В одной из прошлых статей мы рассказывали о создании клиентской части навыков для виртуальных ассистентов на веб-технологиях и обещали вернуться с обзором создания сценарной части на NodeJS. Торжественно сдерживаем своё обещание!
Недавно мы выложили в открытый доступ фреймворк SaluteJS. Он позволяет создавать сценарии для виртуальных ассистентов Салют, используя стандартные методы JavaScript. Поскольку взаимодействие с NLP-платформой реализовано по http, мы подумали, что было бы круто писать сценарии примерно так же, как мы пишем обычные веб-сервисы, используя NodeJS. Вы можете интегрировать SaluteJS с любыми фреймворками вроде next.js, express, hapi или koa. Интеграция выполняется посредством middleware, где вы можете выражать обработку команд ассистента и голосовых команд пользователя, которые приходят в виде обычного http-запроса. Ниже покажу на конкретном примере, как это работает.
Немного теории
Начнём с того, что сценарий, по которому движется пользователь, общаясь с виртуальным ассистентом – обычный http-сервер, который удовлетворяет SmartApp API.
Есть два вида смартапов, где нужен сценарий: Chat App и Canvas App. В обоих случаях без сценарного бэкенда ничего работать не будет.
Схематически для Canvas App и Chat App всё устроено примерно одинаково. Кроме наличия UI и сценария, всегда есть прослойка в виде NLP-платформы. Пользователь может создавать: голосовые команды, клики и т.д. Все эти события проходят через NLP-платформу и попадают в сценарий, та же схема работает и в обратную сторону.
Вот здесь, кстати, можно посмотреть на уже готовые примеры реализации Chat App и Canvas App со сценарием на SaluteJS.
В связке с SaluteJS вам пригодятся рекогнайзеры – ПО, которое умеет классифицировать фразы пользователя, выделять сущности и т.д. Об этом мы тоже позаботились. С их помощью вы можете обрабатывать фразы, используя регулярные выражения, алгоритм на основе коэффицента Сёренсена, а также можете использовать более мощные инструменты – такие как SmartApp Brain.
Для удобства мы написали CLI, которое позволяет синхронизировать локальный конфиг с интентами и сущностями SmartApp Brain в облаке и обратно.
Практическая часть
Пререквизиты
Делать будем на примере с expressjs, кажется, его все знают и отвлекаться на незнакомый API не придется. Ставим модули, создаем index.js, пишем туда реализацию middleware, которая на POST-запрос нам отвечает «оком», запускаем сервер.
import express from 'express';
const port = process.env.PORT || 3000;
const app = express();
app.use(express.json());
app.post('/app-connector', (_, res) => {
res.json({ ok: true });
});
app.listen(port, () => console.log(`Ready on port: ${port}`));
Пока этот код доступен только локально, пользы от него мало. Чтобы было удобно отлаживаться сразу с ассистентом, нам понадобится внешний тоннель. Можно воспользоваться любой тулзой, которая может создать тоннель с localhost в интернет. Пусть это будет ngrok.
Берём URL с https, идём в SmartMarket Studio, создаем проект, указываем URL в качестве вебхука для навыка и сохраняем. Нам это нужно для регистрации навыка в реестре доступных ассистенту. После этой операции мы начнем получать сообщения от NLP-платформы в нашу мидлварьку.
Чтобы работать с рекогнайзером SmartApp Brain по API нам понадобится ключ. Чтобы его получить идем в SmartApp Code и создаем проект из шаблона для SmartApp Brain.
Сохраняем, идём в настройки проекта, в раздел “Классификатор”, и берём ключ.
Кладём этот ключик себе локально куда-нибудь – например, в env-файл – и начинаем писать сценарий. Больше вам не нужно будет никуда ходить, эти пререквизиты необходимо сделать один раз и дальше можно продолжать жить в своей уютной IDE.
Реализация сценарной логики
Чтобы начать, вам понадобится несколько пакетов. Всё, что умеет SaluteJS, мы выразили в составе разных пакетов, чтобы вы могли, как из конструктора, собрать только то, что вам нужно. Для реализации сценария понадобится пакет @salutejs/scenario
, для работы со SmartApp Brain – @salutejs/recognizer-smartapp-brain
, а также адаптер для работы с сессией – @salutejs/storage-adapter-memory
.
После этого нам нужно сделать пул из SmartApp Brain и получить себе локально словарь с интентами и сущностями.
> brain pull
По умолчанию там будут интенты из шаблона, который мы выбрали на этапе создание проекта в SmartApp Code.
{
"intents": {
"/пока": {
"matchers": [{
"type": "phrase",
"rule": "Пока"
}],
},
"/привет": {
"matchers": [{
"type": "phrase",
"rule": "Привет"
}]
}
}
}
Файл будет сгенерирован в src/intents.json
, при желании путь можно указать любой.
Системный сценарий
Самый простой сценарий, который можно запустить и проверить, выглядит следующим образом:
import express from 'express';
import { createIntents, createSystemScenario, createUserScenario } from '@salutejs/scenario';
import model from './intents.json';
const intents = createIntents(model);
app.post(
'/app-connector',
saluteExpressMiddleware({
intents: createIntents(model),
recognizer: new SmartAppBrainRecognizer(),
systemScenario: createSystemScenario({
RUN_APP: ({ req, res }) =>
res.setPronounceText('Привет, мой юный разработчик!'),
NO_MATCH: ({ req, res }) =>
res.setPronounceText('Я не понимаю'),
}),
userScenario: createUserScenario(),
storage: new SaluteMemoryStorage(),
}),
);
Здесь, во-первых, мы видим некий SaluteMemoryStorage
. Когда вы делаете веб-сервис, вам надо уметь работать с сессией. По ходу течения сценария, когда вы задаёте вопросы пользователю, он вам что-то отвечает, нужно где-то сохранять данные, чтобы иметь возможность либо разрешить пользователю идти дальше, либо задать дополнительные вопросы, либо просто сохранить данные себе в базу на веки вечные. Мы сделали несколько простых адаптеров для работы с сессией, а также определили интерфейс для того, чтобы вы могли написать любой свой. В данном примере будем всё хранить в памяти.
Во-вторых, мы реализуем два системных интента: RUN_APP и NO_MATCH.
RUN_APP – сообщение, которое пришлет NLP-платформа, когда вы скажете фразу, запускающую ваш навык. В этот момент можно как-то отреагировать. Мы будем отвечать пользователю что-нибудь в духе «Привет, мой юный разработчик».
NO_MATCH – история про то, когда мы ничего не поняли, когда у нас нет никакого обработчика на то, что говорит пользователь, но нам нужно что-нибудь ему сказать, чтобы диалог выглядел натурально. Это такой аналог «404», который нам нужно адекватно обрабатывать.
Словари
В семействе виртуальных ассистентов Салют три персонажа и не всегда просто выражать их специфичность, поскольку они могут говорить по-разному, задавать разные вопросы и т.д..
Чтобы упростить жизнь разработчику, которому при создании сценария нужно думать, о том, как тот или иной персонаж ответит пользователю, мы сделали так, чтобы можно было определять словари для каждого персонажа отдельно. Переключение этих словарей будет работать автоматически. На уровне кода вы будете обращаться только к ключам в словарях. Принцип работы схож с кейсетами i18n.
Словарь – простой объект, в котором есть ключ и значение в виде фразы, которую нужно использовать.
Таких словарей нужно положить в файловую систему в трех экземплярах и экспортировать из индексного файла.
src/
├── system.i18n
│ ├── sber.ts — словарь для персонажа Сбер
│ ├── joy.ts — словарь для персонажа Джой
│ ├── athena.ts — словарь для персонажа Афина
│ └── index.ts — карта персонажей
// system.i18n/sber.ts
export const sber = {
Пока: 'Привет, мой юный разработчик',
404: 'Я не понимаю',
};
Далее импортируем keyset, который мы создали, в код и передаем словарь в метод i18n объекта request внутри обработчика — handler. Наш сценарий для системных интентов становится чуть сложнее, но теперь он умеет отвечать по-разному – в зависимости от того, какого персонажа сейчас выбрал пользователь.
// ...
import * as dictionary from './system.i18n';
app.post(
'/app-connector',
saluteExpressMiddleware({
intents: createIntents(model),
recognizer: new SmartAppBrainRecognizer(),
systemScenario: createSystemScenario({
RUN_APP: ({ req, res }) => {
const keyset = req.i18n(dictionary);
res.setPronounceText(keyset('Привет'));
},
NO_MATCH: ({ req, res }) => {
const keyset = req.i18n(dictionary);
res.setPronounceText(keyset('404'));
},
}),
userScenario: createUserScenario(),
storage: new SaluteMemoryStorage(),
}),
);
Кроме того, что мы умеем переключать персонажей, словари поддерживают плюрализацию, параметризацию и рандомизацию фраз на один и тот же ключ. Последнее означает, что можно задавать несколько фраз на один ключ, чтобы взаимодействие с ассистентом было естественным.
Пользовательский сценарий
Давайте посмотрим, как это всё работает на примере простой операции – будем складывать два числа и для начала опишем интенты:
{
"intents": {
"/sum": {
"matchers": [{
"type": "phrase",
"rule": "Сложи @num1 и @num2"
}, {
"type": "phrase",
"rule": "Сколько будет @num1 и @num2"
}],
"variables": {
"num1": {
"required": true,
"questions": ["Что с чем?", "Мне нужно больше чисел!"]
},
"num2": {
"required": true,
"questions": ["А какое второе число?"]
}
}
}
}
}
Здесь конфиг с интентами, который мы будем пушить в SmartApp Brain.
Первым ключом мы указываем название интента – это такой айдишник, за которым будет закреплен этот интент. Вы можете выбрать любое название, которое вам нравится, – это не играет никакой роли, лишь бы вам было удобно с этим работать внутри кода.
Далее мы определяем набор фраз, который может сказать пользователь. Если пользователь произнесет что-то похожее на ту или иную фразу, рекогнайзер ответит, что это, с определенной долей вероятности, фраза относится к конкретному интенту, и вы сможете на это отреагировать с помощью хендлера. Таким образом мы заворачиваем вариативность языка в единый хендлер.
В конфиге мы видим, что внутри фраз указаны некие переменные. Это переменные, которые мы можем доставать из фраз. Мы указываем, в каких местах фразы эта переменная может находиться. Далее мы описываем, что эти переменные обязательны для того, чтобы сценарий продолжился. На самом деле, они могут быть и необязательными – в зависимости от того, какой сценарий вы создаете. Кроме этого, если эта переменная обязательная, но пользователь по каким-то причинам её во фразе не сказал, вы можете определить список вопросов, которые ассистент должен задать пользователю, чтобы эти данные дозапросить.
Этот механизм называется “слот-филлинг”, он сработает для обязательных переменных автоматически. И пока пользователь не скажет то, что нам нужно, мы не получим нужные данные, сценарий не пройдёт дальше.
После того, как мы заполнили конфиг с интентами, мы загружаем его в SmartApp Brain с помощью CLI – и модель обучается автоматически.
> brain push
Затем идём и описываем сценарий в коде – что же нам в итоге делать, если пользователь сказал ту или иную фразу.
// ...
userScenario: createUserScenario({
calc: {
match: intent('/sum'),
handle: ({ req, res }) => {
const keyset = req.i18n(dictionary);
const { num1, num2 } = req.variables;
res.setPronounceText(
keyset('{result}. Это было легко!', {
result: Number(num1) + Number(num2),
}),
);
},
},
}),
// ...
Пользовательский сценарий – условный список всех возможных состояний, в которых может оказаться пользователь. Порядок этих состояний определяется вложенностью, предикатом и/или ручным переходом в конкретное состояние с помощью метода dispatch
. Как и в случае с интентами, для каждого состояния задаётся айдишник. После этого вы описываете предикат, он же matcher
, – набор условий или признаков, по которым мы понимаем, что текущее состояние наступило и нужно с этим что-то делать. В данном случае мы работаем с предикатом, который определяет, что человек сказал фразу, которая соответствует интенту sum
.
Дальше мы описываем обработчик, он же handler
, который есть реакция на наступившее состояние. Внутри обработчика мы можем делать буквально что угодно. Вы можете сходить в базу данных, в API или куда-то ещё. Можно делать всё, что вы привыкли делать в middleware для expressjs или где бы то ни было.
Важно отметить, что мы получаем в объекте request уже разобранные переменные, которые мы указали в конфиге для нашего интента. Мы их достанем и положим в объект запроса – вы можете ими воспользоваться и работать так, как будто бы вам это пришло через стандартный json-объект по http, а не как будто с вами говорит живой человек.
В нашем обработчике из примера мы используем магию словарей – передаём результат вычисления параметром в ключ и получаем ответ от ассистента с итоговой суммой чисел, которую назвал пользователь.
Теперь, если запустить сценарий и произнести «сложи 2 и 3», мы услышим ожидаемый ответ ассистента «5. Это было легко».
Механизм платежей
Мы делаем сценарий сложения чисел – простая операция, но почему бы не брать за неё деньги, если человек не знает сумму чисел «2» и «3»?;-)
Попробуем добавить обработку платежей в наш сценарий. Внутри нашего обработчика вместо того, чтобы сразу ответить человеку суммой, мы создадим инвойс, отправим его в SmartPay, получим ответ с Invoice ID и вернём в ответе пользователю не результат, а запрос на то, чтобы он нам за него заплатил:
// ...
userScenario: createUserScenario({
calc: {
match: intent('/sum'),
handle: async ({ req, res, session }) => {
const { num1, num2 } = req.variables;
const { invoice_id } = createInvoice({/*...*/});
session.result = num1 + num2;
res.askPayment(invoice_id);
},
},
}),
// ...
У пользователя на экране появится сценарий оплаты.
После того, как платёж завершится, NLP-платформа пришлёт нам системный интент PAY_DIALOG_FINISHED
. Он может завершиться успехом или неудачей – мы понимаем это по статусу платежа. В данном случае мы проверяем, что статус success
.
// ...
systemScenario: createSystemScenario({
RUN_APP: ({ req, res }) => {
// ...
},
NO_MATCH: ({ req, res }) => {
// ...
},
PAY_DIALOG_FINISHED: ({ req, res, session }) => {
const keyset = req.i18n(dictionary);
if (req.serverAction.parameters.payment_response.response_code === PayDialogStatuses.success) {
res.setPronounceText(
keyset('{result}. Это было легко!', {
result: session.result,
}),
);
}
}),
// ...
Итак, мы поняли, что платёж совершился, и уже здесь отвечаем суммой, которую мы посчитали и заранее сохранили в сессии. Наш пользователь счастлив, мы богаты.
Какие ещё могут быть сценарии
Сценарии могут быть любой вложенности, могут быть неплоскими, идти по веткам. Вы можете вести длительный диалог с пользователем, собирая с него данные. Это всё можно выразить в формате SaluteJS.
Когда у вас много веток, по которым пользователь может ходить, вам нужно уметь переходить из одной в другую. Мы сделали для этого механизм dispatch
, когда вы можете указать любой путь, по которому сейчас нужно отправить пользователя, потому что он в любой момент может передумать и сказать нечто, что должно привести его в другую ветку сразу. Вы просто передаёте хелперу айдишники стейтов в формате массива, и пользователь оказывается в нужном состоянии.
Какие ещё бывают предикаты
Кроме того, что можно сматчиться на интент, мы сделали целый набор типовых матчеров. Они позволяют вам очень гибко оперировать состояниями, которые сейчас происходят с пользователями. Человек может не только говорить, он может также нажимать на контролы, скроллить, навигироваться по страницам и т.д. – и всё это может быть важно. Например, в зависимости от того, на каком экране находится пользователь, одна и та же фраза может нести разный смысл. Это очень важно учитывать.
Типовые матчеры:
intent – наиболее вероятный интент, который мы смогли определить для текущей фразы;
text – прямое совпадение текста. Если вам не нужно умных классификаций и моделей для того, чтобы определить, что за фразу сказал пользователь. Например, если человек говорит «да», и других вариантов у него нет – смысла по этому поводу идти в рекогнайзер нет;
action – на самом деле type, который передаётся внутри server action. Если с фронта нам приходит какой-то server action, мы можем на это описать обработчик;
state – поддерево, которое нам приходит с фронта в случае, если вы создаёте CanvasApp. Мы в формате ItemSelector можем передавать некий набор возможных фраз и/или команд, которые доступны пользователю на данном экране;
selectItem – хелпер, который позволяет искать по поддереву внутри ItemSelector. Например, вы делаете навык про кино и у вас там много фильмов, мы передаём все варианты этих фильмов с фронта, и вы можете с фразой, получив некие переменные от рекогнайзера, пойти искать в этом ItemSelector что-то похожее на то, что сказал пользователь;
match – compose, который позволяет вам собрать любой набор из этих описанных выше матчеров, чтобы более точечно реагировать на действия пользователя. Выглядеть это может примерно так:
match(intent('sum'), state({ screen: 'mainPage' }))
. Этот матчер описывает состояние, когда человек должен сказать конкретный интент на конкретном экране.
В заключение скажу, что мы описали DevGuide и SmartApp API в виде тайпингов. Это значит, что вы можете, не ходя в документацию, создавать, например, карточки для Chat App с автокомплитом и валидацией формата, а также получать автокомплит из доступных интентов и т.д.
Пример из статьи доступен на GitHub – вы можете сходить, попробовать запустить его локально. Вся инструменты, упомянутые в статье, доступны в организации github.com/sberdevices – заходите в гости, задавайте вопросы, создавайте issue. Мы с радостью поможем и ответим на все вопросы.
На developers.sber.ru можно узнать обо всех инструментах и технологиях для создания смартапов и не только.