Pull to refresh

AutoLove: апдейты девушке с YandexGPT

Level of difficultyEasy
Reading time11 min
Views13K

Салют! Меня зовут Григорий, я главный по спецпроектам в AllSee. Как и у многих из нас, у меня есть вторая половинка, и ей свойственно требовать внимания. Сам по себе я человек занятой и мне бывает трудно отвлечься от дел и написать апдейт девушке, из-за чего приходиться терпеть капризы по причине «недостатка внимания». В статье я рассказываю, как YandexGPT и Python-Telegram «уделяют внимание» моей девушке.

Какие вводные?

Есть чат в Telegram, куда нужно писать апдейты: пожелать доброго утра, написать вечером, как я скучаю, спросить, как дела и так далее. Я хочу автоматизировать отправку сообщений в определённые временные слоты. Тексты апдейтов могут быть вариативными и содержать опциональные вопросы. Для генерации апдейтов я буду использовать YandexGPT API, а для взаимодействия с Telegram — python-telegram.

Routines (служебные функции)

Hidden text

Чтение YAML и JSON

def read_yaml(path: str) -> dict:
    with open(path, 'r') as stream:
        return yaml.safe_load(stream)


def read_json(path: str) -> dict:
    with open(path, 'r') as stream:
        return json.load(stream)

Преобразование списков строк формата «00:00» в списки datetime.time

def yaml_to_datetime_time(times_str: list) -> list:
    return [
        datetime.time(
            *map(
                int,
                time_str.split(':')
            )
        )
        for time_str
        in times_str
    ]

YandexGPT API

Перейдём сразу к коду для отправки запросов:

 def send_completion_request(
          self,
          messages: List[dict],
          temperature: float = 0.6,
          max_tokens: int = 1000,
          stream: bool = False,
          completion_url: str = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
  ) -> dict:
      # checking if IAM token and catalog id is set
      if not self._iam_token or not self._catalog_id:
          raise Exception("IAM token and catalog id must be set to send completion requests")
      # sending request
      headers = {
          "Content-Type": "application/json",
          "Authorization": f"Bearer {self._iam_token}",
          "x-folder-id": self._catalog_id
      }
      data = {
          "modelUri": f"gpt://{self._catalog_id}/{self.model_type}/latest",
          "completionOptions": {
              "stream": stream,
              "temperature": temperature,
              "maxTokens": str(max_tokens)
          },
          "messages": messages
      }
      response = requests.post(completion_url, headers=headers, json=data)
      # checking response
      if response.status_code == 200:
          return response.json()
      else:
          raise Exception(f"Failed to send completion request. Status code: {response.status_code}\n{response.text}")

Помимо опциональных параметров temperature, max_tokens, stream, completion_url требуется задать IAM‑токен и ID каталога Yandex Cloud.

Я автоматизировал генерацию IAM‑токена, поэтому для работы будет достаточно передать YAML‑конфиг с указанием следующих полей (как создать ключ авторизации и где взять ID сервисного аккаунта):

ServiceAccountID: 1111111111111
ServiceAccountKeyID: 11111111111
CatalogID: 11111111

Сгенерированный в формате JSON ключ также нужно будет передавать объекту класса ChatGPT.

Вот как выглядит полная реализация класса:

Hidden text
class YandexGPT:
    available_models = [
        'yandexgpt',
        'yandexgpt-lite',
        'summarization'
    ]

    def __init__(
            self,
            model_type: str = 'yandexgpt',
            iam_token: str = None,
            catalog_id: str = None,
            yandex_cloud_config_file_path: str = None,
            yandex_gpt_key_file_path: str = None,
            iam_url: str = "https://iam.api.cloud.yandex.net/iam/v1/tokens"
    ) -> None:
        # setting config files
        self._yandex_cloud_config_file_path = yandex_cloud_config_file_path
        self._yandex_gpt_key_file_path = yandex_gpt_key_file_path
        self._iam_url = iam_url
        # setting model type
        if model_type not in self.available_models:
            raise ValueError(f"Model type must be one of {self.available_models}")
        else:
            self.model_type = model_type
        # setting IAM token
        if not iam_token:
            self._set_iam_token()
        else:
            self._iam_token = iam_token
        # setting catalog id
        if not catalog_id:
            self._set_catalog_id()
        else:
            self._catalog_id = catalog_id

    def _set_iam_token(self) -> None:
        # reading yaml config
        config = read_yaml(self._yandex_cloud_config_file_path)
        # reading json key
        key = read_json(self._yandex_gpt_key_file_path)
        # generating jwt token
        jwt_token = self._generate_jwt_token(
            service_account_id=config['ServiceAccountID'],
            private_key=key['private_key'],
            key_id=config['ServiceAccountKeyID'],
            url=self._iam_url
        )
        # sending request to get IAM token and setting IAM token
        self._iam_token = self._swap_jwt_to_iam(
            jwt_token=jwt_token,
            url=self._iam_url
        )

    @staticmethod
    def _swap_jwt_to_iam(
            jwt_token: str,
            url: str = "https://iam.api.cloud.yandex.net/iam/v1/tokens"
    ) -> str:
        # sending request to get IAM token
        headers = {
            "Content-Type": "application/json"
        }
        data = {
            "jwt": jwt_token
        }
        response = requests.post(url, headers=headers, json=data)
        # checking response
        if response.status_code == 200:
            return response.json()['iamToken']
        else:
            raise Exception(
                f"Failed to get IAM token. "
                f"Status code: {response.status_code}"
                f"\n"
                f"{response.text}"
            )

    @staticmethod
    def _generate_jwt_token(
            service_account_id: str,
            private_key: str,
            key_id: str,
            url: str = 'https://iam.api.cloud.yandex.net/iam/v1/tokens'
    ) -> str:
        # generating jwt token
        now = int(time.time())
        payload = {
            'aud': url,
            'iss': service_account_id,
            'iat': now,
            'exp': now + 360
        }
        encoded_token = jwt.encode(
            payload,
            private_key,
            algorithm='PS256',
            headers={'kid': key_id}
        )
        return encoded_token

    def _set_catalog_id(self) -> None:
        # reading yaml config file
        config = read_yaml(self._yandex_cloud_config_file_path)
        # setting catalog id from config file
        self._catalog_id = config['CatalogID']

    def send_completion_request(
            self,
            messages: List[dict],
            temperature: float = 0.6,
            max_tokens: int = 1000,
            stream: bool = False,
            completion_url: str = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
    ) -> dict:
        # checking if IAM token and catalog id is set
        if not self._iam_token or not self._catalog_id:
            raise Exception("IAM token and catalog id must be set to send completion requests")
        # sending request
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self._iam_token}",
            "x-folder-id": self._catalog_id
        }
        data = {
            "modelUri": f"gpt://{self._catalog_id}/{self.model_type}/latest",
            "completionOptions": {
                "stream": stream,
                "temperature": temperature,
                "maxTokens": str(max_tokens)
            },
            "messages": messages
        }
        response = requests.post(completion_url, headers=headers, json=data)
        # checking response
        if response.status_code == 200:
            return response.json()
        else:
            raise Exception(f"Failed to send completion request. Status code: {response.status_code}\n{response.text}")

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

Python-Telegram

Создадим дочерний от telegram.client.Telegram и YangexGPT класс. Он должен уметь определять временной слот и отправлять сгенерированный текст целевому контакту.

Hidden text
class AutoChat(Telegram, YandexGPT):
    def __init__(
            self,
            target_username: str = None,
            sending_time: List[datetime.time] = None,
            chat_system_prompt: List[str] = None,
            chat_mode: bool = None,
            timezone: pytz.timezone = None,
            telegram_config_file_path: str = None,
            auto_chat_config_file_path: str = None,
            *args,
            **kwargs,
    ) -> None:
        # initializing telegram
        # checking if api_id, api_hash, phone, database_encryption_key, files_directory and login in **kwargs
        if all(
                key
                in kwargs
                for key
                in [
                    'api_id',
                    'api_hash',
                    'phone',
                    'database_encryption_key',
                    'files_directory',
                    'login'
                ]
        ):
            Telegram.__init__(
                *args,
                **kwargs,
            )
        # if there is no then calling custom telegram init method
        else:
            self._init_telegram(telegram_config_file_path)
        # initializing yandex gpt
        # checking if iam_token and catalog_id in **kwargs
        if all(
                key
                in kwargs
                for key
                in [
                    'iam_token',
                    'catalog_id'
                ]
        ):
            YandexGPT.__init__(
                *args,
                **kwargs
            )
        # if there is no then calling custom yandex gpt init method
        else:
            self._init_yandex_gpt(
                *args,
                **kwargs
            )
        # initializing auto chat
        # checking if target_username, sending_time, chat_system_prompt, chat_mode, timezone is set
        if all([
            target_username,
            sending_time,
            chat_system_prompt,
            chat_mode,
            timezone
        ]):
            self._target_user_id = self._get_target_user_id(target_username)
            self._sending_time = yaml_to_datetime_time(sending_time)
            self._chat_system_prompt = chat_system_prompt
            self._chat_mode = chat_mode
            self._timezone = timezone
        # if there is no then calling custom auto chat init method
        else:
            self._init_autochat(auto_chat_config_file_path)

    def _init_yandex_gpt(
            self,
            *args,
            **kwargs
    ) -> None:
        # checking if yandex_cloud_config_file_path and yandex_gpt_key_file_path in **kwargs
        if not all(
                key
                in kwargs
                for key
                in [
                    'yandex_cloud_config_file_path',
                    'yandex_gpt_key_file_path'
                ]
        ):
            raise ValueError(
                "AutoChat args must contain either"
                " 'yandex_cloud_config_file_path' and "
                "'yandex_gpt_key_file_path' or "
                "'iam_token' and 'catalog_id'"
            )
        # if they are than calling custom yandex gpt init method
        else:
            YandexGPT.__init__(
                self,
                *args,
                **kwargs
            )

    def _init_telegram(
            self,
            telegram_config_file_path: str = None,
    ) -> None:
        # checking if telegram_config_file_path is set
        if not telegram_config_file_path:
            raise ValueError(
                "AutoChat args must contain either"
                " 'telegram_config_file_path' or "
                "'api_id', 'api_hash', 'phone', "
                "'database_encryption_key', "
                "'files_directory' and 'login'"
            )
        # if it is than calling custom telegram init method
        else:
            # reading config file
            telegram_config = read_yaml(telegram_config_file_path)
            # initializing Telegram class
            Telegram.__init__(
                self,
                api_id=int(telegram_config['ApiId']),
                api_hash=telegram_config['ApiHash'],
                phone=telegram_config['PhoneNumber'],
                database_encryption_key=telegram_config['DatabaseEncryptionKey'],
                files_directory=os.path.join(
                    os.getcwd(),
                    telegram_config['FilesDirectory']
                ),
                login=telegram_config['Login'],
            )

    def _init_autochat(
            self,
            auto_chat_config_file_path: str = None
    ) -> None:
        # checking if auto_chat_config_file_path is set
        if not auto_chat_config_file_path:
            raise ValueError(
                "AutoChat args must contain ether 'auto_chat_config_file_path'"
                " or 'target_username', 'sending_time', 'chat_system_prompt',"
                " 'chat_mode' and 'timezone'"
            )
        # if it is than calling custom auto chat init method
        # reading config file
        auto_chat_config = read_yaml(auto_chat_config_file_path)
        # setting autochat
        self._target_user_id = self._get_target_user_id(auto_chat_config['TargetUsername'])
        self._sending_time = yaml_to_datetime_time(auto_chat_config['SendingTime'])
        self._chat_system_prompt = auto_chat_config['ChatSystemPrompt']
        self._chat_mode = auto_chat_config['ChatMode']
        self._timezone = pytz.timezone(auto_chat_config['TimeZone'])

    def _get_target_user_id(
            self,
            target_username: str
    ) -> int:
        # getting target user id
        return int(self._get_contact_by_username(target_username)['id'])

    def _get_client_contacts(self) -> List[dict]:
        # getting client chats
        chats = self.get_chats()
        chats.wait()
        # getting contacts from private chats
        contacts = []
        for chat_id in chats.update['chat_ids']:
            chat = self.get_chat(chat_id)
            chat.wait()
            if chat.update['type']['@type'] == 'chatTypePrivate':
                contact = self.get_user(chat_id)
                contact.wait()
                contacts.append(contact.update)
        # returning contacts
        return contacts

    def _get_contact_by_username(
            self,
            target_username: str
    ) -> dict:
        # getting list of all contacts
        contacts = self._get_client_contacts()
        # finding target contact by username
        for contact in contacts:
            if contact['username'] == target_username:
                return contact
        # raise ValueError if there is no such contact
        raise ValueError(f"Contact with username {target_username} not found")

    def send_message_to_target_username(
            self,
            text: Union[str, Element],
            entities: Union[List[dict], None] = None,
    ) -> Union[AsyncResult, None]:
        # sending message to target contact
        return self.send_message(
            chat_id=self._target_user_id,
            text=text,
            entities=entities
        )

    def which_sending_time(self) -> int:
        # getting current time without seconds and microseconds
        current_time = (
            datetime.datetime
            .now(self._timezone)
            .replace(second=0, microsecond=0)
            .time()
        )
        # checking if current time is in sending time list and returning its index or -1 if it is not
        for index, sending_time_unit in enumerate(self._sending_time):
            if current_time == sending_time_unit:
                return index
        return -1

    def check_time_and_send(self):
        # checking if current time is in sending time list
        which_sending_time = self.which_sending_time()
        # if it is than sending message
        if which_sending_time > -1:
            prompt = self._chat_system_prompt[which_sending_time]
            text = self.send_completion_request(
                messages=[
                    {
                        "role": "system",
                        "text": prompt
                    }
                ]
            )['result']['alternatives'][0]['message']['text']
            self.send_message_to_target_username(text=text)

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

Для инициализации родительского класса Telegram будем передавать в AutoChat следующие параметры (как их получить и для чего они нужны смотрите тут и тут):

ApiId: 1111111
ApiHash: 1111111111
DatabaseEncryptionKey: 11111111
FilesDirectory: data
PhoneNumber: '+11111111'
Login: true

А вот настройки связанные с логикой отправки сообщений:

TargetUsername: aaaaaa
SendingTime: [
  '6:15',
  '18:20'
]
ChatSystemPrompt: [
  'Ты - парень программист, у тебя есть девушка. 
  Пожелай твоей девушке доброго утра.
  Можешь спросить опциональные релевантные вопросы у девушки.
  Используй немного предложений: от 1 до 3.
  Можешь использовать эмодзи.
  В качестве ответа дай ТОЛЬКО текст пожелания.
  Если ты дашь дополнительные комментарии к тексту - ты заплатишь штраф 999999 рублей.',
  'Ты - парень программист, у тебя есть девушка. 
  Пожелай твоей девушке хорошего вечера, скажи, что скучаешь.
  Можешь спросить опциональные релевантные вопросы у девушки.
  Используй немного предложений: от 1 до 3.
  Можешь использовать эмодзи.
  В качестве ответа дай ТОЛЬКО текст пожелания.
  Если ты дашь дополнительные комментарии к тексту - ты заплатишь штраф 999999 рублей.'
]
ChatMode: false
TimeZone: Europe/Moscow

Запуск автоапдейтов

Мы вышли на финишную прямую, далее только запуск нашего решения и проверка результатов.

  • Python‑скрипт для запуска и остановки циклов проверки и отправки апдейтов

import threading
import argparse
import signal
import time

from auto_chat.auto_chat import AutoChat


def auto_chat_check_loop(autochat_instance: AutoChat, exit_flag_instance: threading.Event) -> None:
    # infinite loop with check_time_and_send every 60 seconds
    while not exit_flag_instance.is_set():
        try:
            autochat_instance.check_time_and_send()
        except Exception as e:
            print(f"Error while executing check_time_and_send: {e}")
        time.sleep(60)


def stop_program(
        exit_flag_instance: threading.Event,
        autochat_instance: AutoChat,
        autochat_thread_instance: threading.Thread
) -> None:
    print('\n' + 'Stopping the program...')
    exit_flag_instance.set()
    autochat_instance.stop()
    autochat_thread_instance.join()
    print('Program stopped.')


if __name__ == "__main__":
    print('Starting the program...')
    # parsing arguments
    parser = argparse.ArgumentParser(description='AutoChat Configuration')
    parser.add_argument('--telegram-config', type=str, help='Path to Telegram config file')
    parser.add_argument('--auto-chat-config', type=str, help='Path to AutoChat config file')
    parser.add_argument('--yandex-cloud-config', type=str, help='Path to Yandex Cloud config file')
    parser.add_argument('--yandex-gpt-key', type=str, help='Path to Yandex GPT key file')
    args = parser.parse_args()
    # initializing auto chat
    autochat = AutoChat(
        telegram_config_file_path=args.telegram_config,
        auto_chat_config_file_path=args.auto_chat_config,
        yandex_cloud_config_file_path=args.yandex_cloud_config,
        yandex_gpt_key_file_path=args.yandex_gpt_key
    )
    # initializing exit flag
    exit_flag = threading.Event()
    # starting auto chat loop
    autochat_thread = threading.Thread(
        target=auto_chat_check_loop,
        args=(autochat, exit_flag)
    )
    autochat_thread.start()
    print('Program is running...' + '\n' + 'Use Ctrl+C or just exit to stop the program')
    # setting stop signals
    signal.signal(signal.SIGINT, lambda sig, frame: stop_program(exit_flag, autochat, autochat_thread))
    signal.signal(signal.SIGTERM, lambda sig, frame: stop_program(exit_flag, autochat, autochat_thread))
  • Dockefile (укажите актуальные для вас пути к конфигам)

# using python parent image
FROM python:3-slim

# defining workdir
WORKDIR /auto_chat

# copying the current directory contents into the container
COPY ./ /auto_chat

# installing any needed dependencies specified in requirements.txt
RUN pip install --no-cache-dir -r requirements.txt

# installing openssl 1.1 using the package manager
RUN apt-get update
RUN apt-get install -y wget
RUN wget http://nz2.archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2_amd64.deb
RUN dpkg -i libssl1.1_1.1.1f-1ubuntu2_amd64.deb

# running start script (ENTRYPOINT instead of CMD to be sure that our run.py would catch stop signal)
ENTRYPOINT [ \
    "python", \
    "run.py", \
    "--telegram-config", \
    "config/telegram.yaml", \
    "--auto-chat-config", \
    "config/autolove.yaml", \
    "--yandex-cloud-config", \
    "config/yandex_cloud.yaml", \
    "--yandex-gpt-key", \
    "keys/yandex_authorization_key.json" \
]
  • Сборка и запуск контейнера (при первом запуске нужно будет ввести код телеграмм, далее можно использовать detach-режим)

docker build -t auto_chat .
docker run -i --name auto_chat-container auto_chat

Результат

Результат меня радует, поставленную задачу программа решает, однако нужно будет добавить в промпт требование не навязывать помощь😅

Заключение

Я рассказал вам, как решал задачу автоматической отправки апдейтов девушке.

Были ли трудности, оставшиеся за кадром? Абсолютно! Отдельно отмечу танцы с IAM‑токенами. Но, как говорится, всё, что нас не убивает, делает нас сильнее.

Всю кодовую базу вы можете найти в репозитории проекта. Если кто‑то вдохновиться проектом и решит самостоятельно модифицировать мои наработки — буду рад принять ваш pull request.

Что же дальше? Чат в реальном времени, интеграция базы знаний? Обязательно, если это будет вам интересно. Пишите, что думаете в комментариях или мне в telegram. Будем на связи✌️

Tags:
Hubs:
Total votes 35: ↑29 and ↓6+23
Comments25

Articles