Javanese Online

Персистентный конечный автомат, сериализуемые сопрограммы, или архитектура чат-бота

Чат-бот — программа-собеседник, которая поддерживает определённые сценарии общения. В течение сценария бот обычно задаёт вопросы, сохраняет ответы, а в конце исполняет действие.

Технические требования к сценарию я описал так:

Конечно же, первая мысль была про корутины: компилятор сгенерирует конечный автомат, обеспечив целостность, а так как 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
    );
}

Методы расположены в том же порядке, в котором их вызывает роутер. Это позволяет просто прочитать их один за другим и получить представление о том, как сценарий реализован.

Некоторые проблемы, конечно, остались:

Все эти проблемы поняты-приняты. На данном этапе у меня есть типобезопасные сценарии с сериализуемым состоянием, с этим уже можно как-то жить.

Комментарии к статье

{"type":"articleComments","id":"59e7c29c-5a7c-4d9e-8fff-f58abaae7beb","comments":[]}

Javanese.Online в GitHub

Чаты и каналы в Telegram

RSS-лента