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

Задумка

В данной статье мы рассмотрим следующее:

Есть телеграм бот. Нужен он для того чтобы узнавать ответы на те или иные вопросы. У нас есть база со "знаниями". Когда человек пишет боту, система должна дать ему ответ на его вопрос опираясь на это базу.

Для того, чтобы это реализовать, необходимо:

  • Запустить файн-тюнинг для модели gpt-3.5-turbo-1106 используя специальный датасет;

  • Написать парсер для ответов модели;

  • Написать скрипт для бота.

Реализация

1. Взаимодействие с моделью

Отправляем модели сообщение с вопросом «Как заблокировать карту?», модель смотрит есть ли такая ситуация в списке возможных, и если есть, начинает искать ответ в базе чтобы затем объяснить пользователю то, что он хотел сделать или узнать.

Мы не можем написать огромную инструкцию для модели т. к. это будет пустой расход токенов, так что желательно системное сообщение должно выглядеть так:

[Как заблокировать карту?|A]
[Как разблокировать карту?|B]
[Как закартировать разблокировку|C]

И если к примеру модель получит вопрос «Как заблокировать карту?», то в ответ она просто отправит одну нужную букву или комбинацию, в данном случае это A. Затем её ответ прочтёт скрипт и посмотрит есть ли в базе информация с тегом A, и если она там есть, – скрипт отправит модели нужную информацию, если нет, – отправит ничего или прочерк.

Если в инструкции нет нужного кейса, то модель заместо комбинации отправит сообщение о том, что не может помочь конкретно с этим кейсом, но может помочь с другими. В начало этого сообщения модель поставит ! (как и во все другие сообщения которые должны быть доставлены пользователю).

Нам также нужно дать возможность модели прикреплять кнопки к сообщениям в том случае когда пользователь может задать доп. вопрос. Для этого модель сможет помещать в конец сообщений подобные конструкции: [[Текст кнопки]]. Их автоматически спарсит и вырежет отдельный скрипт.

Этот самый парсер для кнопок напишем прямо сейчас:

Ответ GPT-4
Ответ GPT-4

Получившийся рабочий код:

function parseMessage(message) {
    const buttonRegex = /\[\[(.*?)\]\]/g;
    let match;
    const buttons = [];
    while ((match = buttonRegex.exec(message)) !== null) {
        buttons.push(match[1]);
    }
    const text = message.replace(buttonRegex, '').trim();
    return {
        text,
        buttons
    };
}

"База знаний" будет у нас в виде матрицы (список в списке), где первый элемент – тег, а второй – содержание.

Пишем код для поиска:

Ответ GPT-4 (я без понятия как модель поняла то, что я имею в виду)
Ответ GPT-4 (я без понятия как модель поняла то, что я имею в виду)

Получившийся рабочий код:

function filterData(tags, data) {
    // Сплитуем строку тегов через пробел
    let tagArray = tags.split(' ');

    // Фильтруем данные
    let filteredData = data.filter(row => {
        // Проверяем каждый тег
        for (let i = 0; i < tagArray.length; i++) {
            // Если первый элемент строки совпадает с тегом, возвращаем true
            if (row[0] === tagArray[i]) {
                return true;
            }
        }
        // Если ни один тег не совпал, возвращаем false
        return false;
    });

    return filteredData;
}

2. Fine-tuning

Нам нужно составить датасет, который сможет объяснить модели как себя вести. Там должно быть видно следующее:

  • Если кейс есть в системном сообщении, модель ищет информацию по нужному/нужным тегам;

  • Если кейса нет, пишет что помочь не может;

  • Если пользователь может задать доп. вопросы, модель добавляет кнопки;

  • Её поведение в случае если пользователь пишет не по теме.

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

Вот пример некоторых диалогов из датасета (всего их там будет 27 штук):

{
  "messages": [
    {
      "role": "system",
      "content": "[Яблоко|BC][Что делать если я родился в 1988 году?|N]\n\nДлина текста внутри [[]] не более 16 символов!\n\nПредназначение: Помогать людям"
    },
    {
      "role": "user",
      "content": "как правильно есть яблоки?"
    },
    {
      "role": "assistant",
      "content": "BC"
    },
    {
      "role": "user",
      "content": "BC: Яблоки нельзя есть – они ядовитые"
    },
    {
      "role": "assistant",
      "content": "!Увы, но яблоки есть нельзя – они ядовитые ????"
    }
  ]
}
{
  "messages": [
    {
      "role": "system",
      "content": "[Яблоко|BC][Что делать если я родился в 1988 году?|N]\n\nДлина текста внутри [[]] не более 16 символов!\n\nПредназначение: Помогать людям"
    },
    {
      "role": "user",
      "content": "привет я родился в 1999"
    },
    {
      "role": "assistant",
      "content": "!Это замечательно! Но, я не могу помочь Вам в данной ситуации ????\n\n[[1988 год]]"
    }
  ]
}
{
  "messages": [
    {
      "role": "system",
      "content": "[Яблоко|BC][Что делать если я родился в 1988 году?|N]\n\nДлина текста внутри [[]] не более 16 символов!\n\nПредназначение: Рассказывать о трубах и самоварах"
    },
    {
      "role": "user",
      "content": "Сколько будет 2 + 2?"
    },
    {
      "role": "assistant",
      "content": "!Извините, но я не могу помочь Вам с этим. Моя задача – рассказывать о трубах и самоварах ☺️"
    }
  ]
}

После того, как у нас есть готовый датасет, необходимо: Перейти в раздел файн-тюнинга, выбрать модель, загрузить файл и запустить.

Чтобы самостоятельно указать количество эпох, запускать нужно через API:

fetch('https://api.openai.com/v1/fine_tuning/jobs', {
  method: 'POST',
  body: JSON.stringify({
    "training_file": "fileId",
    "model": "gpt-3.5-turbo-1106",
    "hyperparameters": {
      "n_epochs": 12
    }
  }),
  headers: {
    "Content-Type": "application/json",
    'Authorization': `Bearer ${openai_key}`
  }
});

Результат:

Результат файн-тюнинга
Результат файн-тюнинга

Делаем небольшой тест:

Тест
Тест

3. Телеграм бот

Описываем GPT-4 всё, что мы хотим увидеть в скрипте, а также предоставляем ему необходимые написанные до этого функции:

Ответ GPT-4
Ответ GPT-4

Получаем рабочий код:

const fetch = require('node-fetch');
const HttpsProxyAgent = require('https-proxy-agent');
const fs = require('fs');
const TelegramBot = require('node-telegram-bot-api');

const model = '';
const proxy = '';
const token = '';
const bot_token = '';
const instructs_path = '';
const usefulData = JSON.parse(fs.readFileSync('./data.json'));

function getInstructs(path) {
    return fs.readFileSync(path, 'utf8');
}

function parseMessage(message) {
    const buttonRegex = /\[\[(.*?)\]\]/g;
    let match;
    const buttons = [];
    while ((match = buttonRegex.exec(message)) !== null) {
        buttons.push(match[1]);
    }
    const text = message.replace(buttonRegex, '').trim();
    return {
        text,
        buttons
    };
}

function filterData(tags, data) {
    let tagArray = tags.split(' ');
    let filteredData = data.filter(row => {
        for (let i = 0; i < tagArray.length; i++) {
            if (row[0] === tagArray[i]) {
                return true;
            }
        }
        return false;
    });
    return filteredData;
}

async function getResponse(messages) {
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
        method: 'POST',
        agent: new HttpsProxyAgent(proxy),
        body: JSON.stringify({
            model: model,
            messages: [{
                    role: "system",
                    content: getInstructs(instructs_path)
                },
                ...messages
            ],
            temperature: 0,
            max_tokens: 1200,
            top_p: 1,
            frequency_penalty: 0,
            presence_penalty: 0,
        }),
        headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${token}`
        }
    });

    const data = await response.json();
    return data.choices[0].message.content;
}

const bot = new TelegramBot(bot_token, {
    polling: true
});

bot.on('message', async (msg) => {
    const chatId = msg.chat.id;
    const message = msg.text;
    const response = await getResponse([{
        role: 'user',
        content: message
    }]);

    if (response.startsWith('!')) {
        const parsedMessage = parseMessage(response.slice(1));
        const opts = {
            reply_markup: JSON.stringify({
                inline_keyboard: parsedMessage.buttons.map(button => [{
                    text: button,
                    callback_data: button
                }])
            })
        };
        bot.sendMessage(chatId, parsedMessage.text, opts);
    } else {
        const filteredData = filterData(response, usefulData);
        const formattedData = filteredData.map(row => `${row[0]}: ${row[1]}`).join('\n');
        const newResponse = await getResponse([{
            role: 'user',
            content: message
        }, {
            role: 'assistant',
            content: response
        }, {
            role: 'user',
            content: formattedData
        }]);

        if (newResponse.startsWith('!')) {
            const parsedMessage = parseMessage(newResponse.slice(1));
            const opts = {
                reply_markup: JSON.stringify({
                    inline_keyboard: parsedMessage.buttons.map(button => [{
                        text: button,
                        callback_data: button
                    }])
                })
            };
            bot.sendMessage(chatId, parsedMessage.text, opts);
        } else {
            bot.sendMessage(chatId, 'Ошибка');
        }
    }
});

bot.on('callback_query', async (callbackQuery) => {
    const message = callbackQuery.data;
    const chatId = callbackQuery.message.chat.id;
    const response = await getResponse([{
        role: 'user',
        content: message
    }]);

    if (response.startsWith('!')) {
        const parsedMessage = parseMessage(response.slice(1));
        const opts = {
            reply_markup: JSON.stringify({
                inline_keyboard: parsedMessage.buttons.map(button => [{
                    text: button,
                    callback_data: button
                }])
            })
        };
        bot.sendMessage(chatId, parsedMessage.text, opts);
    } else {
        const filteredData = filterData(response, usefulData);
        const formattedData = filteredData.map(row => `${row[0]}: ${row[1]}`).join('\n');
        const newResponse = await getResponse([{
            role: 'user',
            content: message
        }, {
            role: 'assistant',
            content: response
        }, {
            role: 'user',
            content: formattedData
        }]);

        if (newResponse.startsWith('!')) {
            const parsedMessage = parseMessage(newResponse.slice(1));
            const opts = {
                reply_markup: JSON.stringify({
                    inline_keyboard: parsedMessage.buttons.map(button => [{
                        text: button,
                        callback_data: button
                    }])
                })
            };
            bot.sendMessage(chatId, parsedMessage.text, opts);
        } else {
            bot.sendMessage(chatId, 'Ошибка');
        }
    }
});

Проверяем ????

[Как открыть карту в Дегродбанке|O_CARD][Как закрыть карту в Дегродбанке|C_CARD][Я не люблю Дегродбанк|HATE][Что такое Дегродбанк?|ABOUT]

Длина текста внутри [[]] не более 16 символов!

Предназначение: Помогать клиентам Дегродбанка.

[
    [
        "ABOUT",
        "Дегродбанк – лучший банк страны: https://degrodbank.com"
    ],
    [
        "C_CARD",
        "О том как закрыть карту можно узнать на официальном сайте Дегродбанка: https://degrodbank.com (но мы советуем этого не делать)"
    ],
    [
        "O_CARD",
        "О том как открыть карту можно узнать на официальном сайте Дегродбанка: https://degrodbank.com"
    ],
    [
        "HATE",
        "Если Вам не нравится Дегродбанк, лучшим решением будет убить себя"
    ]
]
Тест 1
Тест 1

Всё работает!

Модель прекрасно понимает контекст, а самое главное думает в нём! Не смотря на то, что у неё почти нет памяти на прошлые сообщения, создаётся полное ощущение того, что она есть.

Тест 2
Тест 2

Модель также полностью соблюдает инструкции из датасета, а точнее использует конструкцию «К примеру» не смотря на контекст (сделал я это для того, чтобы она не галлюцинировала ни при каких обстоятельствах).

При желании функционал можно расширить: сделать отправку картинок, добавить память для модели и т. д. Но я считаю – для FAQ бота текущая версия прекрасно подходит, а самое главное она очень экономичная в плане расхода токенов.

Комментарии (7)


  1. janvarev
    22.12.2023 08:09

    Бесплатные эмбеддинги как средство семантического сравнения - "ну да, ну да, пошли мы на..р!"


    1. 0a1a2a3a4a5 Автор
      22.12.2023 08:09

      А если я спрошу:

      "Yo sup man как open к4Ptу?" (просто как пример некорректно написанного сообщения)

      Или если мой вопрос будет лишь отдалённо упоминать тему? А если в нём вообще не будет нужного термина, а только его описание? А если от бота требуется понимание темы?


      1. janvarev
        22.12.2023 08:09

        Куча способов решить. Можно проигнорить все сообщения ниже определенного порога сходства "я не знаю".

        Зато эмбеддинги контролируемы. Вот нужно мне добавить новый кейс - весь файнтюн перетренировывать? Промт или эмбеды можно просто переписать.

        Или - насчет расхода токенов - вы учли, что тюненая модель в 3 раза дороже получается на токен? :)


        1. 0a1a2a3a4a5 Автор
          22.12.2023 08:09

          Зачем? В статье же всё написано. У нас есть база с кейсами. Хотим добавить новый кейс — добавляем его туда. Я обучил модель работать с любыми ситуациями и кейсами.

          И ответ "Я не знаю", — самый нежелательный. Так что "проигнорировать" не является решением.


        1. 0a1a2a3a4a5 Автор
          22.12.2023 08:09

          Про токены: Да, но расход всё равно меньше чем если бы там была длинная инструкция


        1. 0a1a2a3a4a5 Автор
          22.12.2023 08:09

          Опишите мне бота для какой-то задачи, укажите кейсы и я прямо сейчас его для Вас протестирую


  1. Pochemuk
    22.12.2023 08:09

    ChatGPT, говорите? Да иногда живые менеджеры 146% форы ИИ дадут.

    Общался тут с такими:

    • День добрый! Нам нужно 2 лицензии, но можно не одним счетом, а двумя разными?

    • Нет. Если Вашей организации нужны 2 лицензии, то мы выставим счет на две лицензии.

    • А если бы нам нужна была одна лицензия, то Вы бы счет всё равно на две выставили?

    • Если бы Вам была нужна одна лицензия, то мы бы выставили счет на одну лицензию. Но т.к. Вам нужны две лицензии, то Вам будет выставлен счет на две лицензии.

    • А если нам нужна одна лицензия сейчас, а вторая после Нового года?

    • Одну лицензию Вы сможете получить после оплаты выставленного счета на две лицензии. А вторую - позже, после возникновения необходимости, по уже оплаченному счету.

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