Бот-гадалка на ChatGPT

Один из пет-проектов которые я периодически придумываю\создаю. Всегда интересно сделать что-нибудь не-банальное. ChatGPT довольно быстро стали использовать для диалогов по ролям, применяя команды «act like a …», однако в в большинстве случаев эти диалоги все равно используют как замену гуглу. То есть пытаются получить от бота скомпилированное экспертное знание.

Мне же интересно — на что еще его можно раскрутить. То есть, не возможности его знаний, а возможности его логики когда знания ограничены. Обычную интеграцию с последней версией я перенес в Телеграм и рабочие чаты довольно быстро, а теперь хотелось заставить его отвечать не на обычные вопросы (гугление), а на что-то необычное.

Гадание подошло идеально. Во-первых, к вопросам заданным гадалке не существует готовых ответов. То есть — их невозможно найти, они должны быть именно «придуманы». Во-вторых, невозможно обвинить гадалку в том что ответ «неправильный», т.к. невозможно в качестве аргументации обвинения предоставить «правильный» ответ. То есть какой бы ответ не был выдан гадающему — всегда можно подтвердить его позицией «я художник, я так вижу».

Я поискал различные способы гаданий, стараясь выбрать:

  1. что-то более классическое
  2. что-то подходящее под формат чата, а не гадание по линиям на ладони
  3. что-то, что в современном мире не кажется зашкварным,
  4. что-то, что стало смещаться в область оккультных развлечений (типа гаданий на суженого или доски с буквами)

В результате карты таро показались мне вариантом более-менее удовлетворяющим всем требованиям. Я выбрал гадание на раскладе из трех карт. Карты должны открываться по одной, по мере гадания, и желательно показать их визуально. Поэтому выстраивать более долгий диалог с мельканием большого числа картинок на экране было бы утомительно.

Чтож, идея есть, начнём ее материализовать.

Чтобы диалог не был скучным — я решил сделать несколько картинок. На входе в диалог, на окончании на тасовании колоды, и конечно же саму колоду. В качестве основы картинок для кард взял колоду Таро Райдера — Уэйта.

Т.к. карты в исходном виде телеграм старался кропнуть до более удобного ему соотношения сторон — пришлось их пакетно обработать добавив под них «подложку» избавляющую от проблем с кропом изображения.

Вот пример того что получилось

Т.к. я не хотел упростить себе задачу и заложить внутрь и так весьма сомнительной практики какой-то обман — я решил что сам процесс гадания с технической стороны должен быть реализован как оцифровка реального процесса гадания, без каких-нибудь ухищрений и упрощений.

Упрощенное описание процесса

Сама процедура гадания может варьироваться, в самом общем виде она выглядит следующим образом:

  1. Гадатель должен сформулировать вопрос, на который предполагается получить ответ.
  2. Колода должна быть тщательно перемешана в «закрытом» виде (то есть так, чтобы карты были обращены рубашкой вверх). Если гадатель учитывает прямое и перевёрнутое положение карт (см. ниже), перемешивание должно производиться на достаточно большой гладкой поверхности (столе) круговыми движениями, так, чтобы карты имели возможность повернуться.
  3. Из перемешанной колоды тем или иным способом отбирается нужное для гадания количество карт, которые выкладываются на столе рубашкой вверх в определённом порядке (так называемый «расклад»).
  4. Затем в порядке, определяемом раскладом, карты «открываются» — переворачиваются лицевой стороной вверх, или все сразу, или поочерёдно, по мере продвижения толкования.
  5. Каждая открытая карта расклада интерпретируется в соответствии со своим собственным значением и положением в раскладе.
  6. Отдельные толкования карт объединяются гадателем в связное короткое повествование, описывающее проблему, причины её возникновения и/или перспективы развития ситуации.

Оцифровка процесса

Чтобы снизить серьезность того как воспринимается само гадание, гадалку я заменил на …. кота.

При старте он приветствует пользователя, шутит и чуть чуть рассказывает о себе. Т.к. бот сделано в телеграме, а телеграм позволяет получить кое-какую информацию о пользователе который запускает бота — я сделал так чтобы бот обращался по имени. Думаю это немного добавляет атмосферности.

Я решил из наиболее популярных быстрых раскладов взять расклад из трех карт, которые символизируют прошлое, настоящее, и будущее. По нажатию на кнопку «погадать» начинается гадание.

Небольшие пет-проекты я обычно стартую на Google Apps Script и только при росте\развитии портирую куда-то на python\google cloud и т.п. Именно поэтому код ниже будет на JS.

Итак, нам надо:

Принять от пользователя запрос

Получаем его в виде строки в рамках стартового диалога

Перемешать колоду

У нас есть колода на 78 карт. Колоду я реализовал как массив объектов, где каждая карта представлена объектом с параметрами названия значения карты, ссылки на изображение

function tarotDeck() {
    var deck = [
        {
            arcana: 'Major',
            name: '0 – The Fool',
            description: 'Шут - карта, которая символизирует свободу, беззаботность, игривость и неопределенность. Она также может указывать на необходимость открытости к новым возможностям и приключениям, а также на необходимость не бояться ошибок и неудач.',
            file_id: "AgACAgIAAxkBAAIBHmR-XD0m-aQKMl5ZTFIAAWe6CYQjWwACttMxG5eI8Ut59c52WYHMQwEAAwIAA3kAAy8E"
        },
        {
            arcana: 'Major',
            name: 'I – The Magician',
            description: 'Маг - карта, которая символизирует силу воли, умение управлять своими мыслями и эмоциями, а также способность к творчеству и интуиции.',
            file_id: "AgACAgIAAxkBAAIBFWR-W2VRMRl_k9REywEpirU78-NWAAKz0zEbl4jxS6owmE9wueXVAQADAgADeQADLwQ"
        },
        // -----------------------------------------
        {
            arcana: 'Minor',
            name: 'Queen of Pentacles',
            description: 'Королева Пентаклей - карта, которая символизирует богатство, процветание и умение управлять своими финансами. Она также может указывать на необходимость заботы о своем здоровье и благополучии.',
            file_id: "AgACAgIAAxkBAAIB-WR-aHGBzu0raaPQEttgD3BTZC7iAAIM1DEbl4jxS_EXZssl21tFAQADAgADeQADLwQ"
        },
        {
            arcana: 'Minor',
            name: 'King of Pentacles',
            description: 'Король Пентаклей - карта, которая символизирует власть, авторитет и умение управлять бизнесом или карьерой. Она также может указывать на необходимость принятия ответственности за свои поступки и принятия решительных действий.',
            file_id: "AgACAgIAAxkBAAIB_GR-aI7J8NwPvOSBiCIrzziiaq9NAAIN1DEbl4jxS1XZ53TFS-pGAQADAgADeQADLwQ"
        }
    ];
    return deck;
}

С технической точки зрения я не увидел никаких причин перемешивать колоду отдельно от вытягивания карт.

Достать из нее нужно количество карт

Итак, нам нужно чтобы карты которые достаются из массива карт были случайными. Есть множество способов это реализовать, я наверно выбрал самый тривиальный:

// Собирает расклад из трех случайных неповторяющихся карт
function getRasklad() {
    let rasklad = [];
    while (rasklad.length != 3) {
        let card = getRandomCard();
        if ((card != rasklad[0]) && (card != rasklad[1])) {
            rasklad.push(card);
        }
    }
    Logger.log(rasklad);
    return rasklad;
}

// Выдает случайную карту из колоды
function getRandomCard() {
    let deck = tarotDeck();
    let card_id = Math.floor(Math.random() * deck.length)
    let card_obj = {
        id: card_id,
        card: deck[card_id]
    }
    Logger.log(deck[card_id].description);
    return card_obj;
}

Истолковать значение карты в контексте вопроса пользователя

Отличие от живой гадалки — мы истолковываем карты «в скрытую». Просто чтобы не отправлять по 2 сообщения на каждую карту — в первом открывая ее, а во втором истолковывая.

Толкование я переложил на плечи ChatGPT с моделью gpt-3.5-turbo. Общаться с ChatGTP буду с помощью библиотеки которую написал раньше — https://github.com/pamnard/OpenAIApp

Контекст для модели можно задавать не только в самом вопросе, но и сохраняя диалог в массив messages, и каждый раз отправляя его с последним вопросом. Таким образом с первым запросом к модели нам нужно задать чуть больше исходных данных, а с каждым последующим вопросом контекст будет расти т.к. каждый предыдущий ответ модели будет становится частью контектса.

function chatgpt(ask) {
    const api_key = "13412347612347856123896451238641239645";
    const openai = new OpenAIApp(api_key);
    const rasklad = getRasklad();
    const temp = 1;
    const top_p = 1;
    
    // Инициализируем массивы для хранения ответов и сообщений
    var reply_to_guest = [];
    var messages = [];
    
    // Проходим по каждой карте в раскладе
    for (var i = 0; i < rasklad.length; i++) {
        
        // Если это первая карта, формируем текст сообщения о ней
        if (i === 0) {
            let text = `Ты профессиональная гадалка-цыганка. К тебе пришел посетитель, погадать на картах таро. \nГадание будет в виде расклада из трех карт - Прошлое, Настроящее и Будущее. \nВ качестве того "о чем именно гадаем" - посетитель ответил "${ask}". \nКарта "прошлого" которая ему выпала, это "${rasklad[i].card.description}" \nИстолкуй эту карту для посетителя в контексте его вопроса. Не говори о чем в третьем лице - ответь именно ему.`;
            let message_obj = {
                role: 'user',
                content: text
            }
            messages.push(message_obj);
        }
        if (i === 1) {
            let text = `Карта "настоящего" которая ему выпала, это "${rasklad[i].card.description}" \nИстолкуй эту карту для посетителя в контексте его вопроса. Не говори о чем в третьем лице - ответь именно ему.`;
            let message_obj = {
                role: 'user',
                content: text
            }
            messages.push(message_obj);
        }
        if (i === 2) {
            let text = `Карта "будущего" которая ему выпала, это "${rasklad[i].card.description}" \nИстолкуй эту карту для посетителя в контексте его вопроса. Не говори о чем в третьем лице - ответь именно ему.`;
            let message_obj = {
                role: 'user',
                content: text
            }
            messages.push(message_obj);
        }
        
        // Отправляем сообщение в OpenAI и получаем ответ
        let response = openai.Chat().CreateChatCompletion({
            model: 'gpt-3.5-turbo',
            messages: messages,
            temperature: temp,
            top_p: top_p
        });
        
        // Обрабатываем ответ, добавляем его в массив ответов и сообщений
        let answer = response.choices[0].message.content.replace(/^\n\n/, '')
        reply_to_guest.push(answer);
        messages.push({
            role: 'assistant',
            content: answer
        })
    }
    
    // Формируем финальное сообщение
    let final_text = `Теперь истолкуй посетителю расклад в целом. Помни, что карты не говорят ничего о намерениях и мыслях посетителя, только о возможностях поворотов судьбы. Что сулит посетителю такое сочетание карт в контексте его вопроса? Не говори о чем в третьем лице - ответь именно ему.`;
    let final_message_obj = {
        role: 'user',
        content: final_text
    }
    messages.push(final_message_obj);
    
    // Получаем финальный ответ от OpenAI
    let response = openai.Chat().CreateChatCompletion({
        model: 'gpt-3.5-turbo',
        messages: messages,
        temperature: temp,
        top_p: top_p
    });
    let final_answer = response.choices[0].message.content.replace(/^\n\n/, '')
    reply_to_guest.push(final_answer);
    
    // Возвращаем расклад и ответы
    return {
        rasklad: rasklad,
        reply: reply_to_guest
    }
}

Открыть их по очереди

На этом этапе у нас есть вопрос пользователя, значения карт, их изображения и толкование. Мы могли бы послать пользователю это всё за один раз, 4 сообщениями без задержек между ними, но это выглядит как-то не очень удобно. Ведь пользователю надо их прочитать в правильном порядке а для этого придется скролить вверх… В общем фу.

Я сделал под каждой открытой и истолкованной картой кнопку «Дальше». Работает это следующим образом — т.к. у нас фактически есть ответы для всего последующего диалога, но мы не знаем с какой скоростью пользователь будет по диалогу двигаться — мы сохраним не-отправленные сообщения диалога в кеш. Первую карты мы открываем по мере готовности ответа, а остальные достаем по коллбеку от телеграма:

async function sendRemainCard(chat_id, user_id) {
    const remain_cache = cache.get(chat_id + '_' + user_id + '_remains');
    Logger.log(remain_cache);
    if (remain_cache != null) {
        const remain_json_obj = JSON.parse(remain_cache);
        const arr = remain_json_obj.remains_arr;
        let caption_one = arr[0].reply;
        const photo_one = arr[0].rasklad?.card?.file_id;
        if (photo_one) {
            caption_one = `<strong>${arr[0].rasklad.card.name}</strong>\n\n${arr[0].reply}`;
        }
        const remains_arr = arr.slice(1);
        cache.put(
            chat_id + '_' + user_id + '_remains',
            JSON.stringify({
                remains_arr: remains_arr
            }),
            21600
        );
        if (photo_one) {
            await myBot.sendPhoto({
                photo: photo_one,
                chat_id: chat_id,
                caption: caption_one,
                parse_mode: 'HTML',
                reply_markup: type.inlineKeyboardMarkup({
                    inline_keyboard: [
                        [{
                            text: 'Дальше 👉',
                            callback_data: `get_next_card`
                        }]
                    ]
                })
            });
        } else {
            cache.remove(chat_id + '_' + user_id + '_remains');
            await myBot.sendPhoto({
                photo: 'AgACAgIAAxkBAAP_ZH4HV9jTLifbF56MgkILVgFiG30AAgTKMRv3efFLaEC5izsP5pwBAAMCAAN5AAMvBA',
                parse_mode: 'HTML',
                chat_id: chat_id,
                caption: caption_one
            });
        }
    }
}

Истолковать расклад в целом

В принципе я уже в предыдущем пункте описал создание и финального сообщения. Для него используется статичная картинка, т.к. расположить три карты в ряд, чтобы нормально выглядело на экране мобильного телефона пока не получилось.

Публичную версию запускать не стал. Если есть интерес побаловаться самому, делюсь исходным кодом на гитхабе — https://github.com/pamnard/TaroBot