Создаем ChatGPT ассистента на основе КПТ методики Альберта Эллиса.

Опубликовано Sun 16 June 2024 в Blog

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

  • Haystack — разработка приложений на базе искусственного интеллекта.
  • Chainlit — интеграция чата в веб-интерфейсе.

С готовым результатом можно ознакомиться по ссылке: https://w3const.ru/

Требования

  • Python >= 3.9
  • OpenAi api ключ (https://platform.openai.com/api-keys)

Знакомство с Haystack

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

Давайте сформируем ментальную модель, чтобы в дальнейшем нам было проще ориентироваться.

Используя Haystack, весь процесс работы с данными может быть поделен на три этапа: Сырые данные (Raw Data), Компоненты (Components) и Пайплайн (Pipeline). Принцип работы haystack фреймворка

  1. Любой пользовательский запрос — это сырые данные, будь то текст, аудио, файл или изображение. Их мы воспринимаем как "Raw Data".

  2. При получении сырых данных нам потребуется их подготовка. Например, определение формата, классификация, конвертация из одного формата в другой, форматирование, очистка от нежелательных символов, объединение в один результирующий файл и т.д. За этот этап отвечают компоненты ("Components").

  3. От первого пользовательского запроса до получения конечного результата данные "протекают" этап за этапом из одного компонента в другой (Pipeline).

Резюмируем:

На самом верхнем уровне у нас есть конвейер (Pipeline), в который добавляются компоненты (Components).

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

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

Детальнее о всех доступных компонентах и примерах можно ознакомиться в документации: https://docs.haystack.deepset.ai/docs/get_started

Схема взаимодействия

Рассмотрим обобщенную схему взаимодействия приложения, построенного по принципу коммуникации с внешними LLM-моделями.

Принцип работы построения приложений на базе LLM моделей

  1. Получаем запрос от пользователя.

  2. Модифицируем запрос, добавляя дополнительные инструкции, условия и ограничения, если требуется. Эта модификация позволяет настроить взаимодействие с моделью на определённую форму коммуникации.

  3. Транслируем запрос в модель.

  4. Получаем ответ.

  5. При необходимости модифицируем ответ перед отправкой пользователю, например: можем извлечь определённую часть ответа.

  6. Транслируем пользователю готовый результат.

Задача

Мы хотим создать ассистента, который будет эмулировать терапевтическую сессию по методике рационально-эмоционально-поведенческой терапии "ABC"

Есть три варианта реализации задачи (от сложного к простому):

  1. Fine-tuning/дообучение модели под конкретную специфику:

    • Настройка LLM моделей под конкретную задачу путем дообучения на собственном массиве данных.
  2. Retrieval-Augmented Generation (RAG)/Поисково-улучшенная генерация:

    • Предварительный поиск наиболее релевантного документа из собственной базы данных и передача в LLM модели в виде контекста с указанием строгих инструкций для работы и генерации ответа исключительно на основе переданного контекста.
  3. Assigning Roles and Prompt Engineering/Назначение ролей и инструкционный "промптинг":

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

В данном материале мы рассмотрим третий вариант. Сперва узнаем, знаком ли ChatGPT с нужной нам методикой.

Подготовка к разработке

Подготовка

В первую очередь подготовим все необходимые пакеты, переменные окружения и сформируем базовый скелет проекта:

# Устанавливаем зависимости
pip install haystack-ai \
python-dotenv \
chainlit \
langfuse-haystack
# Создаём и настраиваем .env файл
OPENAI_API_KEY= #Получить и указать openAi api ключ (https://platform.openai.com/api-keys)
# Создаём рабочий файл main.py и подготавливаем базовый скелет
from dotenv import dotenv_values
import logging

# Подгружаем файл переменных окружения
config = dotenv_values(".env")
OPENAI_API_KEY = config["OPENAI_API_KEY"]

# Добавим логирование для отслежевания ошибок
logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

async def main() -> None:
    # Здесь будет дальнейший код...

if __name__ == "__main__":
    main()

Опишем этапы, через которые нам потребуется пройти

# main.py
from dotenv import dotenv_values
import logging

# Подгружаем файл переменных окружения
config = dotenv_values(".env")
OPENAI_API_KEY = config["OPENAI_API_KEY"]

# Добавим логирования приложения для отслежевание ошибок
logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

async def main() -> None:
    # 1. Получаем пользовательский запрос

    # 2. Дополняем пользовательский запрос, нашей собственной инструкцией
        # 2.1. Назначаем роль
        # 2.2. Задаём строгую структуру взаимодействия, чтобы исключить произвольный и не ожидаемый результат в ответе от модели.
        # 2.3. Добавляем память/контекст в виде предидущего диалога, чтобы помочь вести наиболее осмысленный диалог с пользователем.

    # 3. Транслируем в LLM модель

    # 4. Получаем результат, проводим анализ ответа и извлекая предназначенный для пользователя ответ.

    # 5. Транслируем готовый результат пользователю.


if __name__ == "__main__":
    main()

Создание интеллектуального агента

Веб интерфейс чата

На этапе подготовки мы установили библиотеку Chainlit — это готовый веб-интерфейс чата, через который будет происходить взаимодействие с пользователем. Давайте добавим его в наш код и настроим получение пользовательского запроса.

# Предидуший код...

# Импортируем chainlit
import chainlit as cl

# Применяем функцию обёртку on_message, для нашей основной функции main. Теперь при отправки пользовательского сообщения в чате, мы будем его получать в нашей функции main.
@cl.on_message
async def main(message: cl.Message) -> None:
    # 1. Получаем пользовательский запрос
    user_query = message.content

    # В целях тестирования работоспособности транслируем пользователю его же запрос обратно
    await cl.Message(
    content=user_query,
    ).send()

if __name__ == "__main__":
    main()

После чего запускаем приложение командой chainlit run main.py -w открываем в браузере по адресу: http://localhost:8000 и тестируем

Тестируем первый вариант

Настройка haystack

Выше мы уже рассмотрели теоретические аспекты, теперь приступим к настройке компонентов.

# Предидуший код...

# Импортируем компонент OpenAIChatGenerator для взаимодействия с ChatGpt
from haystack.components.generators.chat import OpenAIChatGenerator
# Импортируем компонент DynamicChatPromptBuilder для построения шаблона обращения к ChatGpt
from haystack.components.builders import DynamicChatPromptBuilder
# Импортируем вспомогательный класс ChatMessage для удобного построения сообщений
from haystack.dataclasses import ChatMessage

@cl.on_message
async def main(message: cl.Message) -> None:
    # 1. Получаем пользовательский запрос
    user_query = message.content

    # Производим настройку
    gpt = OpenAIChatGenerator(api_key=Secret.from_token(OPENAI_API_KEY), model="gpt-3.5-turbo")
    prompt_builder = DynamicChatPromptBuilder()
    # Создаём конвейер и добавляем в него компоненты
    pipe = Pipeline()
    pipe.add_component("chat_prompt_builder", prompt_builder)
    pipe.add_component("gpt", gpt)
    # Связываем компоненты между собой
    pipe.connect("chat_prompt_builder.prompt", "llm.messages")

    # Добавляем пользовательское сообщение полученное из чата
    messages = [
        ChatMessage.from_user(user_query)
    ]

    # Транслируем в ChatGpt
    response = pipe.run(
        data={
            "prompt_builder": {"prompt_source": messages}
        }
    )

    # Извлекаем готовый результат:
    response_message = response["gpt"]["replies"][0].content 

    # Отправляем обратно пользователю
    await cl.Message(
        content=response_message,
    ).send()

if __name__ == "__main__":
    main()

На данном этапе мы уже реализовали приложение, которое транслирует пользовательское сообщение в ChatGPT, дожидается ответа и возвращает результат. Однако пока оно является обычным транслятором без передачи истории сообщений, дополнительных инструкций и указаний следовать определенным правилам. Давайте доработаем наш код, добавив системные сообщения и память:

  • Системные сообщения нам потребуются для настройки взаимодействия с моделью на определенный лад, тем самым снижая вероятность неожиданных ответов от модели.

  • Память нам потребуется для передачи предыдущей истории диалога, чтобы помочь модели сгенерировать наиболее релевантный ответ. Мы будем передавать последние 5 сообщений.

ChatGPT категоризирует сообщения на 4 типа:

  • assistant: сообщение, содержащее текст модели

  • user: сообщение, содержащее пользовательский текст

  • system: сообщение, содержащее системную инструкцию

  • function: сообщение, содержащее информацию о доступных инструментах

from haystack import Pipeline
from haystack.dataclasses import ChatMessage, ChatRole
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.builders import DynamicChatPromptBuilder
from haystack.utils import Secret
from dotenv import dotenv_values
import logging
import chainlit as cl
import re

config = dotenv_values(".env")
OPENAI_API_KEY = config["OPENAI_API_KEY"]
logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)


# Подготавливаем список для хранения истории
message_hist = []

# Подготавливаем функцию для извлечения истории сообщений чата.
def get_history(messages: list) -> list:
    # Фильтруем, исключая системные сообщения и возвращаем 5 последних. В данной функции, нам достаточно получить только диалоговые сообщения. Т.е (assistant и user)
    messages = list(filter(lambda message: message.role == ChatRole.USER or message.role == ChatRole.ASSISTANT, messages))
    return [
        *messages[-5:]
    ]

# Подготавливаем функцию для построения системной инструкции
def build_template(query, history):

# Строим шаблон запроса к ChatGpt. Данный шаблон строит динамическое сообщение, которое мы будем передавать при каждом запросе. Оно строиться из 3 элементов: 

# 1. Системное сообщение - статическое/неизменяемое для каждого запроса
# 2. history - динамическое, 5 последних сообщений взаимодействия между пользователем и ChatGpt
# 3. query - динамическое, текущий запрос пользователя

    return [
        ChatMessage.from_system(f"""
        Твоя роль, выступать в роли AI ассистента. AI ассистент является терапевтом,
        который проводит терапевтическую сессию по КПТ методике Альберта Эллиса - ABC.
        Задача ассистента вести логически последовательный диалог продвигаясь по терапевтической сессии.
        Проанализируй пользовательский запрос и сформулируй логически последовательное продолжение.

        Ожидаемый ответ:
        Reflection: [Процесс саморефлексии. Анализ пользовательского запроса и определения на каком этапе терапевтической сессии сейчас находимся. 1. Установление контакта и установление рабочего отношения. 2. Идентификация проблемы. 3. Анализ ABC: A (активирующее событие). B (убеждения). C (последствия). 4. Изменение мышления. 5. Разработка стратегий и действий. 6. Оценка и заключение.] (Обязательно включать в ответ)
        Throught: [Логическая цепочка рассуждения AI ассистента. На его основы принимаются дальнейшие решения] (Обязательно включать в ответ)
        Answer: [Ответ Ai ассистента на основе Throught и Reflection. Если есть затруднения или неуверенность в формировании готового ответа, то ответь заготовленным текстом: "К сожалению затрудняюсь продолжить диалог"] (Обязательно включать в ответ)

        Ниже приведена предидущая история разговора:
        """),
    *history,
    ChatMessage.from_user(f"Query: {query}")
]

# Подготавливаем вспомагательную функцию для извлечения готового результата по паттерну, который был описан в системной инструкции функции build_template выше
async def extract_answer(response) -> str:
    pattern = r"Answer:\s*(.*)"
    match = re.search(pattern, response)
    if match:
        result = match.group(1).strip()
        return result
    else:
        return ""

# Подготавливаем вспомагательную функцию для извлечения процесса рассуждения по паттерну, который был описан в системной инструкции функции build_template выше. Дополнительно применяем функцию обёртку @cl.step для вложенной визуализации в диалоге
@cl.step
async def extract_throught(response) -> str:
    pattern = r"Throught:\s*(.*)"
    match = re.search(pattern, response)
    if match:
        result = match.group(1).strip()
        return result
    else:
        return ""

# Обновляем функцию main с учётом новых функций
@cl.on_message
async def main(message: cl.Message) -> None:
    # 1. Получаем пользовательский запрос
    user_query = message.content
    # Инициализируем компонент взаимодействия с ChatGpt
    gpt = OpenAIChatGenerator(api_key=Secret.from_token(OPENAI_API_KEY), model="gpt-3.5-turbo")
    # Инициализируем компонент построения запросов для ChatGpt
    prompt_builder = DynamicChatPromptBuilder()
    # Создаём конвейер и добавляем в него компоненты
    pipe = Pipeline()
    pipe.add_component("prompt_builder", prompt_builder)
    pipe.add_component("gpt", gpt)
    # Связываем внутри pipline компоненты между собой, чтобы pipileni понимать в какой последовательности передавать данные
    pipe.connect("prompt_builder.prompt", "gpt.messages")
    # Строим шаблон сообщения
    messages = build_template(user_query, get_history(message_hist))
    # Транслируем в ChatGpt
    response = pipe.run(
        data={
            "prompt_builder": {"prompt_source": messages}
        }
    )

    # Извлекаем готовый результат:
    response_message = response["gpt"]["replies"][0].content 
    # Извлекаем процесс рассуждения
    throught = await extract_throught(response_message)
    # Извлекаем ответ от ChatGpt
    answer = await extract_answer(response_message)

    # Добавляем новый ответ от ChatGpt в историю
    messages.append(ChatMessage.from_assistant(response_message))
    message_hist.extend(messages)

    # Отправляем извлечённый ответ пользователю
    await cl.Message(
        content=answer,
    ).send()

if __name__ == "__main__":
    main()

Проверка результата

Тестируем конечный результат

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

  1. Анализ пользовательского запроса.

  2. Процесс рассуждения/саморефлексии для определения дальнейшего этапа терапевтической сессии.

  3. Формирование логически последовательного ответа.

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

Дополнительные ссылки