Чат-бот — программа-собеседник, которая поддерживает определённые сценарии общения. В течение сценария бот обычно задаёт вопросы, сохраняет ответы, а в конце исполняет действие.
Технические требования к сценарию я описал так:
- целостность: объявление шага сценария и данных, хранимых на этом шаге, неразделимо. Можно описать все возможные шаги enum'ом, но данные будут отдельно — придётся просто помнить, что на таком-то шаге заданы такие-то поля, а это путь в динамическую типизацию;
- сериализуемость: состояние сценария можно сохранить в базу данных, а затем прочитать, в том числе из кода другой версии (или из другого экземпляра сервиса);
- композируемость: отдельный сценарий не знает о вызывающем сценарии и максимально отвязан от вызываемых подсценариев.
Конечно же, первая мысль была про корутины: компилятор сгенерирует конечный автомат, обеспечив целостность, а так как suspend-функциям позволено вызывать другие suspend-функции, композируемость тоже в порядке.
А вот с сериализуемостью всё сложно: Continuation не реализуют java.io.Serializable, kotlinx.serialization тоже не спешит решать эту проблему. Можно взять любой рефлективный сериализатор (хоть Gson/Moshi) и расковырять Continuation рефлекшеном. В любом случае, изменение или перекомпиляция кода грозит несовместимыми изменениями формата сериализации, оставляя проблему версий нерешённой. Более того, я не представляю себе, как решить проблему версий, не вводя в котлин очередную большую и сложную языковую фичу, которая позволила бы явно контролировать формат континюэйшена.
Ещё одна сложность с корутинами заключается в том, что ими не очень удобно выражать нелинейный код. Диалог с пользователем может подвиснуть на одном вопросе, если пользователь никак не удосужится дать ответ, понятный боту:
var answer = /*suspend*/receiveAnswer()
while (!answer.isCorrect) {
sendMessage("Ничего не понял. Пожалуйста, введи целое число или нажми кнопку")
answer = /*suspend*/receiveAnswer()
}
Уходит линейность — теряется выразительность корутин.
Второй кандидат для описания состояния конечного автомата — sealed-классы. Они могут нести с собой дополнительные данные (в отличие от enum), но вот про сериализацию ничего не знают — нужно либо промазывать аннотациями, либо описывать сериализацию отдельно.
Будем использовать семантику sealed-классов и разберём примитивный сценарий:
Пользователь: Я хочу разослать сообщения (точка входа в сценарий)
Бот: Кому?
Пользователь: какой-нибудь предикат
Бот: Какой текст?
Пользователь: йа текст
Бот: отправляет «йа текст» всем пользователям, которые подходят под «какой-то предикат»
Результат работы любой функции бота я моделирую с помощью типа Update — он описывает, какие сообщения послать в ответ и в каком состоянии после этого окажется диалог. Если опустить перегруженные для удобства конструкторы и методы, класс выглядит так:
public final class Update<STATE> {
private Iterator<? extends BotApiMethod<?>> effects;
private final STATE state;
public Update(Iterator<? extends BotApiMethod<?>> effects, STATE state) {
this.effects = effects;
this.state = state;
}
public STATE state() {
return state;
}
public Iterator<? extends BotApiMethod<?>> consumeEffects() {
Iterator<? extends BotApiMethod<?>> eff = effects;
if (eff == null) throw new NoSuchElementException("already consumed");
effects = null;
return eff;
}
public <RS> Update<RS> mapState(Function<? super STATE, ? extends RS> transform) {
return new Update<RS>(consumeEffects(), transform.apply(state));
}
public Update<STATE> append(Iterator<? extends BotApiMethod<?>> effectsFollowing) {
return new Update<>(Iterators.concat(consumeEffects(), effectsFollowing), state);
}
public Update<STATE> prepend(Iterator<? extends BotApiMethod<?>> effectsPreceding) {
return new Update<>(Iterators.concat(effectsPreceding, consumeEffects()), state);
}
}
Я смоделировал состояние сценария с помощью Lychee:persistence — так формат сериализации всегда будет описан явно. Так как проект изначально написан на джаве, я продолжаю эту традицию, сохраняя тем самым высокую скорость компиляции.
final /*тип скрыт ради безопасности вашей кукухи*/ stateType = either(
"predicate", unit(),
"text", new Box("predicate", string)
);
Такой тип читается как «Either predicate or text». Я для себя определяю Either как частный случай частичной структуры, у которой всегда задано ровно одно поле.
"predicate", unit() описывает, что первый вариант — это пустой кортеж. В JSON это выглядит так: {"predicate":{}}. Сама пустышка {} неинтересна, а вот по наличию "predicate" понятно, что мы спросили пользователя, кому рассылать сообщения, и ждём ответа.
"text", new Box("predicate", string) описывает второй вариант — кортеж из одного элемента. JSON: {"text":{"predicate":"какой-то предикат""}}. В этот момент пользователь уже ввёл предикат, а бот попросил его ввести текст и ждёт ответа.
Если бы был ещё один промежуточный шаг (давайте спросим пользователя, когда отправлять сообщения), его тип снова был бы длиннее предыдущего. Например,
final … stateType = either3(
"predicate", unit(),
"time", new Box("predicate", string),
"text", new Tuple("predicate", string, "time", dateTime)
);
Вариант text должен хранить время из предыдущего шага time, но и нельзя терять предикат. JSON: {"text":{"predicate":"сохраняем из прошлого шага","time":"время"}}
Также имеется функция-роутер: она принимает состояние сценария (если есть) и сообщение пользователя, а возвращает Update — список действий и новое состояние.
public Update<Optional<Either<Unit, Struct<Box<String, /*…*/>>>>>
handle(
Optional<Either<Unit, Struct<Box<String, /*…*/>>>> state,
Message message
) {
// у меня есть свой Option.fold, который принимает
// Producer<R> для пустого опшенала и Function<T, R> для непустого
return fold(state,
// нам передали пустое состояние — стартуем новый сценарий
() -> begin(message.getChatId(), Optional.of(EitherLeft(Unit.INSTANCE))),
// после этого прыгаем в состояние 1 ----^^^^^^^^^^^^^^^^^^^^^^^^^
// непустое состояние — продолжаем с промежуточной точки, коих у нас две:
presentState -> fold(presentState,
// пользователь ответил, кому отправлять сообщения
unit -> receivePredicate(message) // Update<Optional<String>>. Пустой, если мы не поняли пользователя
.mapState(optStr -> Optional.of(optStr.isPresent()
// если предикат распарсился, оборачиваем и возвращаем:
? EitherRight(instantiateBox(predicate))
// если нет — возвращаем старое состояние:
: state)),
// пользователь ответил, какой текст отправить
writeText -> receiveText(firstOf1(writeText), message, Optional.empty())
// разворачиваем предикат--^^^^^^^^ покидаем сценарий--^^^^^^^^
)
);
}
Если отложить в сторону эмоции (и топор), можно заметить, что длина типа состояния растёт неадекватно быстро: () | (field1) | (field1, field2) | (field1, field2, field3) | (field1, field2, field3, field4) | …
Тут напрашивается интересный вывод: состояние — это частичная структура, поля которой добавляются по одному в строгом порядке.
Для этого случая я завёл тип Incremental. Больше никаких Either с центром масс в районе хвоста.
Incremental<A, B, C, …, N> содержит либо ничего, либо одно лишь A, либо кортеж (A, B), либо (A, B, C) и так далее.
Этот тест показывает, как инкрементал работает: mapFold принимает по функции для каждого возможного количества заданных полей, позволяет добавить значение для следующего поля и вернуть любое выражение. Я вижу здесь семантику и map, и fold, отсюда и название.
// внезапный котлин! Личи целиком на нём.
private fun Incremental3<String, String, String>.saturatingFill(
): Incremental3<String, String, String> =
mapFold( // next — это функция, которая принимает следующий элемент инкрементала
// и возвращает новый, дополненный инкрементал
{ next -> next("a") }, // ( ) + a => (a )
{ _, next -> next("b") }, // (a ) + b => (a, b )
{ _, _, next -> next("c") }, // (a, b ) + c => (a, b, c)
{ _, _, _ -> this } // (a, b, с) => (a, b, c)
)
@Test fun saturation() {
var inc = emptyIncremental<Incremental3<String, String, String>>()
assertNotEquals(inc, inc.saturatingFill().also { inc = it })
assertNotEquals(inc, inc.saturatingFill().also { inc = it })
assertNotEquals(inc, inc.saturatingFill().also { inc = it })
assertEquals(inc, inc.saturatingFill())
assertSame(inc, inc.saturatingFill())
}
Переписываю роутер:
final … stateType = incremental(new Box<>("predicate", string)));
public Update<Optional<Incremental1<String>>>
handle(Optional<Incremental1<String>> optState, Message message) {
return fold(optState,
() -> begin(message.getChatId(), Optional.of(emptyIncremental())),
// прыжок в первое состояние и возврат пустого инкрементала.
// В JSON он выглядит как `{}`
state -> mapFold(state,
next -> receivePredicate(message)
.mapState(optStr -> optStr.isPresent()
? optStr.map(next::invoke)
: Optional.of(state)),
// Either ушёл, пришёл Incremental.
// next() добавляет значение следующего поля
// а здесь всё осталось по-старому:
predicate -> receiveText(predicate, message, Optional.empty())
)
);
}
Добавление параметра теперь выглядит так:
-final … stateType = incremental(new Box<>("predicate", string)));
+final … stateType = incremental(new Tuple<>("predicate", string, "time", dateTime)));
-public Update<Optional<Incremental1<String>>>
+public Update<Optional<Incremental2<String, DateTime>>>
-handle(Optional<Incremental1<String>> optState, Message message) {
+handle(Optional<Incremental2<String, DateTime>> optState, Message message) {
return fold(optState,
() -> begin(message.getChatId(), Optional.of(emptyIncremental())),
(state) -> mapFold(state,
(next) -> receivePredicate(message)
.mapState(optStr -> optStr.isPresent() ? optStr.map(next::invoke) : Optional.of(state)),
- (predicate) -> receiveText(predicate, message, Optional.empty())
+ (predicate, next) -> receiveText(predicate, message).mapState(next::invoke)
+ (predicate, text) -> scheduleMessages(predicate, text, Optional.empty())
)
);
}
Приблизительный код всех остальных методов сценария для полноты картины:
private <R> Update<R> begin(long chatId, R resp) {
return new Update<>(
new SendMessage(chatId, "Кому будем отправлять сообщения?"),
resp
);
}
private Update<Optional<String>> receivePredicate(Message msg) {
Optional<String> predicate = extractPredicate(msg);
return new Update<>(
new SendMessage(
msg.getChatId(),
predicate.isEmpty()
? "Ничего не понял, но очень интересно."
: "Введите текст сообщения"
),
predicate
);
}
private <R> Update<R> receiveText(String driverPredicate, Message message, R state) {
Long customerId = message.getChatId();
return new Update<>((Iterable<? extends BotApiMethod<?>>) StreamEx
.<BotApiMethod<?>>of(new SendMessage(customerId, "Отправляю…"))
.append(Stream.of(Unit.INSTANCE).map(unit -> {
// здесь код отправки, которую спровоцирует тот,
// кто будет итерировать стрим
return new SendMessage(customerId, "Отправлено столько-то сообщений");
})),
state
);
}
Методы расположены в том же порядке, в котором их вызывает роутер. Это позволяет просто прочитать их один за другим и получить представление о том, как сценарий реализован.
Некоторые проблемы, конечно, остались:
- длинный тип. Так как в джаве нет тайпалиасов, приходится править его в нескольких местах — либо завернуть во что-нибудь;
- роутер пишется на каком-то эзоповом языке, читать непросто. Набор методов для упрощения жизни пока только формируется, до специальной библиотеки ещё далеко;
- большинство промежуточных шагов можно логически разделить на две части. (1) парсим ответ пользователя и ругаемся на плохой ввод, (2) при хорошем вводе прыгаем на следующий шаг. Здесь это разделение не показано;
- если на каком-то шаге происходит ветвление, то его тип — это длинный Either<IncrementalX<…>, IncrementalY<…>>. Типы очень быстро выпрыгивают на экран.
Все эти проблемы поняты-приняты. На данном этапе у меня есть типобезопасные сценарии с сериализуемым состоянием, с этим уже можно как-то жить.