Немного опоздал с трендом, но не пропадать же добру просто так. Может, кому-нибудь пригодится (например, для того, чтобы посмеяться или кринжануть с человека, который год своей жизни потратил на что-то вроде этого).
Наверняка вы слышали о нашумевшей в своё время ИИ стримерше NeuroSama. Меня тогда очень заинтересовала эта тема, и я решил во что бы то ни стало повторить задумку автора, несмотря на то, что некоторые моменты стримов вызывали у меня кое‑какие сомнения.
Мое внимание привлекло не само шоу и все эти нашумевшие самые «крутейшие» моменты стримов, а сам факт того, что нейросеть реально может полностью автономно и полноценно вести стрим, удерживая внимание зрителей!
В этой статье я расскажу о попытке создать свою нейро‑тян для русского сегмента, которая сможет автономно и без перерывов играть и вести трансляции на различных стриминг‑платформах и |
Статья получилась без преувеличения огромной из‑за совмещения просто ТУЧИ разных технологий и необходимости погружения в тонкости некоторых, поэтому запасайтесь бочкой кваса и ванной попкрона, как и в прошлый раз, приключение обещает быть жарким, но не только потому, что скоро лето, а ещё потому, что сейчас весна (и сопутствующее весеннее обострение), ведь мы с вами будем создавать настоящую (виртуальную) девушку‑стримера!
Спойлер: будет весело, иногда сложно и очень интересно как опытному бойцу, так и простому обывателю!
Если не хотите читать всё — воспользуйтесь спойлером «портал».
Портал (в самые интересные моменты)
Этот раздел — что-то вроде содержания, но не совсем, нет четкой структуры. Отсюда вы сможете быстро пройтись по интересующим разделам статьи! Для этого просто жмакаем на подсвеченные ссылки (произойдёт магия: переход к якорю — нужному разделу)!
⚠️Если вы собираетесь прыгнуть сразу в конец статьи или какую-то её часть, обязательно ознакомьтесь с дисклеймерами!
Это — не содержание. Это — список ссылок на некоторые значимые моменты.
-
Общее обмозговывание
Выбор игры (угадайте с 1 попытки)
Схема всей системы (пикча, абстрактная)
-
Начинаем писать код (Java-часть)
⚠️CodeDisclaimer (если будете читать код дальше, важно)
Пишем код Java-части бота (код Java, много)
АД или парсинг чата из этой игры (код Java, regex, пикчи)
?Киборг-убийца в действии (гифки, демонстрация)
-
Соединение игры с виртуальным аватаром
InGameCaptchaSolver.useless (пикчи, код Java, Python, Tensorflow OCR)
MineBridge.py (код Python, Py4j. Не особо интересно, но понадобится..)
?Хвостатая строит глазки (гифки, демонстрация)
-
Варим кисель TyanGpt (много code)
⚠️Disclaimer (важно, если читать статью далее)
Придумываем хвостатой название (текст, но весело)
?️Анализатор ников даёт жару (вырезки чата, весело)
-
Думаем над диалоговой системой (текст, вводные)
много важных вводных моментов, например, ранги и эмоции
абзац про настроение
Схема диалоговой системы (пикча, абстрактная)
Система модерации (пикча, а выше код Python)
База данных (пикча и ниже код Python & sql)
-
Веселимся! (Обязательно читайте Disclaimer)
-
?️Первые шаги в «социуме» (вырезки чата)
Задушевные беседы (ref: джентльмены у подъезда на лавочках)
История угасшей любви...
Баги (тоже весело)
-
?️Взросление (весёлые вырезки чата)
Отработка токсичных комментариев
Просто забавные моменты
-
Добиваем остатки (TTS и остальное, код)
-
Статус
Ссылки (потыкать, подписаться, канал и т.п.)
Тыкните, пожалуйста, последний опрос после статьи)
Какие технологии будут использоваться (спойлеры)
Нейросети: NLP (генеративная LLM, классификаторы), TTS, STT, чуток CV
(расшифровки аббревиатур будут даны далее в статье)
Языки:Python, немного Java
Пакеты Python:pytorch, multiprocessing & multithreading, flask, websocket, asyncio, pysimplegui, py4j, sqlite, a bit tensorflow
ПО: IDE JetBrains PyCharm Community и Intellij Idea, OBS, VTube Studio, Docker Desktop (WSL)
Пару слов про формат публикации и целевую аудиторию
Обычно в таких случаях, как у меня, статью делят на несколько частей, чтобы собрать больше «классов» и отложить написание второй части когда‑нибудь на потом. Но я не любитель тянуть быка за яйца, поэтому наслаждайтесь полной версией сразу! А ещё мне просто лень делить на две части, придумывать название для них, снова вводить теги и т. д. =)
Ещё про формат
Касаемо формата, изначально я задумывал статейку как туториал, но по факту повторить сделанное будет очень трудно, и уж точно вряд ли кто‑то будет тратить месяцы своей жизни на полное «следование» туториалу. Кроме того, я не публиковал весь проект на GitHub (только некоторые части) и оставил здесь только необработанные фрагменты кода для самых заинтересованных (подробнее). По этим причинам я убрал со статьи пометку туториал, несмотря на то, что повествование далее будет вестись именно как в туториале, в формате, похожем на инструкцию создания чего‑либо.
Про целевую аудиторию. Я постарался (но не везде получилось) написать статью так, было понятно для новичка и при этом послужило нескучным чтивом крутому спецу.
Начинающим этот опыт может где‑то помочь вкатиться, правда, сразу в кучу областей... Крутым спецам — поржать с костыльного бредокода и ещё раз вспомнить, как лучше не делать. А простому читателю без изначальных навыков в профильной сфере — с интересом понаблюдать за моими мучениями страданиями пытками (так и не получилось подобрать подходящий синоним под процесс разработки с нуля), оценить соотношение затраченных сил к успеху (СПОЙЛЕР: бесконечность разделить на 0.0001) и подчерпнуть для себя какие‑то моменты, которые прикольно будет рассказать друзьям.
Что такое эти ваши тян, стримеры и т.д.
Прекрасно понимаю по себе (да-да, сам из числа тех, до кого «тренды», если их можно так назвать, доходят в последнюю очередь), что кто‑то может не въезжать во какие-то молодёжные понятия по типу «тянка» и пр. В этом спойлере я постараюсь кратко объяснить значения этих и подобных понятий, которые могут использоваться далее в статье. Для всезнающих, кстати, тоже может быть приятно почитать, навевает такую ностальгию...
Что касается гугла – да, эта база... Но в нашем случае я предпочту не только дать какие-то конкретные определения, но и объединить их со своим пониманием, ведь тот же гугл может (поверьте, может) выдать нерелевантную инфу и запутать человека, тем более, если перед нами совсем «зелёный» новичок.
Стрим — прямая онлайн‑трансляция. Наиболее популярными для стримов являются платформы Twitch, YouTube, Trovo и др. Как правило, стримеры ведут трансляции, чтобы получать донаты (от donate — «жервтовать») — добровольные пожертвования. Ради этих самых донатов, а ещё для роста метрик популярности трансляций (лайков, просмотров и пр.) некоторые стримеры и стримерши способны творить всякую дичь, вплоть до раздевания перед камерой или уничтожения своего имущества. Стримеры, как правило, развлекают аудиторию с помощью игр, просмотров видосиков и прочего. Стримерши — аналогично, только у них на вооружении есть, кхм, как бы это сказать, некоторые дополнительные «аргументы»...
Тян (тянка, tyan) — девушка в переводе с японского. Понятие стало молодёжным, так как суффикс «тян» активно использовался в японских мультипликациях – аниме. Вообще, с аниме связано огромное количество трендов современной молодёжи, начиная от коллекционирования различных значков и заканчивая самым настоящим аниме‑сектантством (шутка). Анимешники — особо ярые знатоки аниме, несмотря на свою безвредность, во время своего зарождения активно подвергались буллингу со стороны особо консервативных малолетних неанимешников, многие из которых впоследствии тоже стали анимешниками. В общем, это всё очень интересная тема, кому интересно, можете продолжить поиски в гугле.
Дакимакура — подушка с изображением тянки из аниме. Обязательный атрибут любого уважающего себя анимешника (шутка).
Токсик — недоброжелательный в общении человек, часто злой, стремится задеть чувства других.
Постановка задачи
До начала проектирования общего прототипа системы я сразу определил несколько требований:
Чтобы нейросеть могла во что‑то играть. Необязательно сама нейросеть, но геймплей должен быть полностью автоматическим.
Чтобы эта нейросеть могла взаимодействовать с игрой, как минимум, с игровым чатом и пользователями путём общения. Игра также должна быть многопользовательской.
Стримерша должна иметь виртуальный аватар, любым образом реагирующий на действия в игре (нам нужна живая 2д тян, а не дакимакура).
Чтобы стримерша не получила бан на платформе сразу же после запуска (этот пункт включает в себя необходимость автомодерации) и при этом могла «весело» реагировать на агрессивное поведение, «подкалывая» токсичных пользователей.
Стримерша должна иметь возможность коммуникации в первую очередь на русском языке.
Коммуникация должна происходить в нескольких видах: в устном (синтез и распознавание речи) и письменном (текстовые сообщения в игре, в чате на Ютубе, Твитче и т. д.)
Система должна работать полностью автономно без участия разработчика как минимум несколько часов.
Резюмируя вышесказанное, наша (или, если я вам не нравлюсь, моя) цель — сварганить ИИ стримершу с виртуальным аватаром, которая будет без перерывов играть в игру, одновременно отвечать на вопросы зрителей и игроков, развлекать их и подкалывать токсиков! Система должна быть интересной и «весёлой», а также знать, когда нужно «остановиться» в своём «веселье», чтобы не получить блокировку.
Пайплайн дальнейшей работы выстроим следующим образом:
Определим стек технологий. Превратим цель в задачи. Спроектируем общую программную архитектуру.
Разработаем автоматическую игровую программу (игровой бот).
Разработаем вспомогательные системы.
Свяжем системы между собой.
Развернём систему и проведём тестовый стрим.
Проанализируем результаты и доработаем систему.
Будем повторять пункты 5–6 до тех пор, пока не будет достигнуто удовлетворительное качество работы.
Поймём, что всё это время занимались бесполезной фигней и впадём в депрессиюОтпразднуем кастрюлей кваса наше прекрасное творение и наконец ляжем поспать впервые за год. В моём случае, кстати, поспать не получилось, ведь у меня на носу защита диплома, а ещё надо сдавать вступительные в вуз, который, к тому же, необходимо ещё выбрать...
Выбор технологий
Итак, у нас есть общие требования, теперь можно переходить и к более конкретным вещам. Для создания такого сложного (это достаточно мягко сказано) объекта, как автоматическая виртуальная стримерша, нужно, как минимум, определить стек технологий и выделить из них те, которые наиболее подходят к нашему сценарию использования.
Игра
Minecraft. Ну этот выбор даже объяснять не нужно, он очевиден.
Для тех, кому нужно объяснение
Для всех потерянных сфероидов обделенных квадратностью людей напоминаю, что майнкрафт — это не просто игра, это жизнь, которая дарит многочисленным пользователям смысл к существованию в период депрессии, который у них наступает сразу после возвращения с уроков конечно же работы с зп 999к $/наносек.
Автоматический игровой агент
Для сборки первого прототипа нейростримерши я не хотел заморачиваться над ИИ игроком — мне достаточно было того, чтобы был скрипт, который сам мог бы играть на каких‑либо многопользовательских серверах, поэтому я решил просто забабахать с помощью какой‑нибудь не самой сложной системы пару функций для игры с людьми (например, в миниигре SkyWars боту достаточно будет лутать ресы и убивать игроков, получать и отправлять сообщения в чат).
Размышления о MineRL
Чтобы позволять нейросети ориентироваться и управлять игрой, разработчик нейростримерши NeuroSama, предположительно, использовал MineRL, для которой характерно подергивание мыши, как мы видим на некоторых стримах.
Однако, совсем неочевидно, как разработчик связал нейросеть визуального управления игрой и текстовую генеративную нейросеть (предполагаю, что никак, а все моменты, демонстрирующие «понимание» нейросетью игрового процесса подстроены для «контента», но доказательств у меня нет да и мы не для этого здесь собрались, так что оставим совесть разработчика в покое).
Кому интересна тема визуального управления ИИ в Minecraft, можете поглядеть MineDojo. Из последних разработок интерес привлекает VOYAGER (к сожалению, не тот самый космический аппарат, а реализация агента управления игрой Minecraft через ChatGPT и бота MineFlayer JS), я же пойду путём попроще — просто использую мод на Minecraft, который позволит управлять игрой при помощи команд.
Очень повезло, что на глаза попался AltoClef — мод для Minecraft (Fabric), который может полностью автоматически пройти игру с помощью своей иерархической системы тасков (задач). Например, таск «пройти игру» включает в себя задачу «добыть дерева»; задача «добыть дерева» раскладывается на задачи «найти дерево», «поломать дерево» и т. д. Предполагалось с помощью AltoClef замутить аналогичную задачу «выиграть в SkyWars», в которую будет входить убийство игроков, лутинг сундуков и т. п. Движение в AltoClef реализовано при помощи старого доброго «навигатора» для майнкрафт — Baritone (многие заблуждаются, что это ИИ, однако там рулит обычный алгоритм, основанный на эвристиках).
Так как моды для Minecraft обычно пишут на Java, хорошо бы заранее подумать о связи мода с центральным скриптом, который явно будет на Python (далее раскрою почему). И тут нам на помощь приходит старый добрый друг гугл, в котором по соответствующему запросу сразу же выплывает инструмент для связи Java и Python с говорящим названием Py4j, который вполне позволяет сделать внутренний сетевой мост между модом на Java и скриптом на Python.
Синтез речи
Если кратко: нам нужен голос тянки. Так как я описываю свои раздумия спустя много времени, на тот момент в результате поиска по тг каналам актуальным TTS был Silero, а для роли тянки у силеро имелся замечательный голос Baya. В рамках его лицензии CC BY‑NC мы могли бы использовать этот голос хотя бы для прототипа, а уже потом при развитии проекта его можно было бы заменить на другой, благо индустрия не стоит на месте и к тому моменту мы могли бы увидеть более открытые, доступные и качественные ТТСки с более подходящими голосами и лицензиями.
Кому интересна данная тема, можете также посмотреть Bark (офигенный, но тяжелый и медленный) и топовые наработки TeraTTS (очень классно, особенно голос GLaDOS, но хочется больше [голосов]).
Виртуальный аватар
В качестве ПО для виртуального аватара решено было использовать VTube Studio из‑за возможности удобного подключения к API системы через Python в дальнейшем.
Для самого аватара была выбрана Live2D модель «LiveroiD_A‑Y01» от японского разработчика. На сайте публикации модели было указано, что автор разрешает бесплатное использование её для видео и прямых трансляций. Модель также доступна в Steam. Опять же, при развитии проекта и появления стойкого образа персонажа можно будет сделать рестайлинг и выпустить собственную модель.
Примечательно, что популярная нейростримерша NeuroSama изначально функционировала на стандартной встроенной в VTube Studio модели «Hiyori Momose» и никто автора за это особо не критиковал =)
LLM
В качестве ЛЛМки я решил использовать мой ранее рассмотренный FRED‑T5. С того времени появилось много крутых файн‑тюнов модели, наиболее классными мне показались инструкт‑фреды от SiberianSoft. Отдельное внимание здесь хочу уделить Денчику, эксперту по фредам и вообще ллм в целом, который очень помог мне разобраться, огромное и человеческое ему спасибо!
Важным дополнением будет тот факт, что, как вариант, ллмку удобно будет запихать на докер, ведь так мы сможем удобно навешивать любую ллм независимо от платформы. Интерфейс взаимодействия с центральной программой можно сделать через сеть методом синхронизации, например, с помощью Python SyncManager.
Где GPT-4? Почему не OpenAI? Почему не Лама? Что за фред такой?
На момент создания нейростримерши (а это было давно) автор (да‑да, я) был не достаточно удовлетворен модерацией от API OpenAI. Дело в том, что в майнкрафте достаточно много токсиков, а нейросеть на тот момент, скажем так, «скучненько» отрабатывала токсичные запросы, а ведь именно на API OpenAI я пытался вначале строить весь пайплайн. Кроме того, я не абы‑кто, а великий студент, живущий в съемной однушке, не моё это царское дело тратить какие‑то сущие доллары на токены GPT-4 (тогда вообще была только GPT-3.5) API и покупать 10xH100 для инференса ламы 9999b...
Конечно, ламы, мистрали, чатжпт (и пр.) явно будут поумнее, чем Фред, однако мы ведь делаем не сверхумный чат‑бот, знающий всё обо всём. Для прототипа нам бы сгодилась весёлая тролль‑балаболка чуть лучше ранних версий Яндекс Алисы, которая будет необязательно понимать, что происходит, но поддерживать диалог и временами выдавать какие‑нибудь весёлые и иногда странные фразочки, а Фред, как мы убедились, даже без файнтюна это умеет очень круто! Кроме того, Фред выдавал и «эмоции» по заданной мной инструкции, поэтому давайте попробуем поработать именно с ним.
Тем не менее, если в будущем этот проект вдруг станет популярным, естественно, я сменю Фреда на более крутую и умную модель и задействую больше ресурсов, чем сейчас. На случай, если в будущем фред вдруг эволюционировал, стал самым сильным ИИ, захватил мир и читает эту статью: дружище, я пошутил, никто тебя менять не собирался, это была всего лишь ШУТКА:)
Предварительная модерация сообщений (антибан)
А как вы хотели? Если вы читали мою прошлую статью, вы, наверное, понимаете, что Фред может сказануть что‑то «очень не очень», поэтому тут без вариантов нужна модерация. Перебор списком слов? Нее, слишком скучно (можно добавить как дополнительный этап, но точно не как основной). Конечно же сделаем по максимуму: зарубим классификацию по токсичности фраз как пользователей, так и нейросети, а ещё, чтобы было веселее, будем закидывать это как‑нибудь в промпт, чтобы нейронка получала инфу про токсика в виде чего‑то такого: «Данный пользователь рассуждает на тему расизма и использует вульгарную лексику». Классификаторов есть уйма, например, от s‑nlp или apanc. Большинство распространяются под лицензией CC BY‑NC‑SA, на первое время (для прототипа) пойдёт, а, если разрастётся, несложно и свой написать или, может, к тому времени уже что‑то более открытое выкатят.
Центральный скрипт управления
Итак, когда я с горем пополам прикинул примерный стек технологий, надо придумать, как удержать в узде этих лебедей, раков и щук. (можно подумать, что под раками я подразумеваю игроков, но не совсем, я использую отсылку на эту пословицу как метафору, в том смысле, что мы имеем дело со слишком разными технологиями)
Как я уже заметил ранее, разумно будет писать центральный скрипт на Python. К Ютубу обращаемся через Google YouTube Data API, с модом в игре у нас мост Py4j, с OBS (стриминговая программа) и VTube Studio можно связаться через WebSocket API. Для хранения контекста диалога и данных о пользователях будем юзать БД (базу данных) sqlite и встроенный интерфейс работы с ней в Python.
Так как имеем дело со множеством высоконагруженных отдельных узлов, будем использовать мультипроцессорную архитектуру Python (просто напишем скрипт для связи с каждой из частей и погрузим каждый скрипт в отдельный процесс), и свяжем эти процессы через общие очереди (multiprocessing.queue
) и пространства имён (multiprocessing.namespace
) и будем передавать их как аргументы в каждый процесс при старте. По большей части асинхронное программирование реализовано не будет, потому что автор создаёт прототип и главная цель — чтобы оно вообще всё как‑то работало, а улучшать уже можно будет потом. Мультипроцессинг здесь — костыльное и неоптимизированное решение, но сгодящееся для прототипа.
В главный скрипт, кроме спавнера вспомогательных процессов, также должны войти системы выбора комментариев (не на все же сообщения отвечать), что‑то вроде RAG (связанная с БД система, которая будет дополнять промпт в зависимости от инфы о пользователе и других данных) и система получения и отправки сообщений для Ютуба и Твитча (чем больше поддерживаемых платформ — тем лучше).
На этом этапе я выкатил что‑то вроде схемы стека технологий, чтобы сформировать общее представление того, что мы делаем.
Так, всё! Устали мы от нудной теории! Перейдём же, наконец, к практике! Начнём, пожалуй, с игровой части, для меня это самое сложное... (естественно, лучше начинать с простого, но в этом случае я не знал, смогу ли вообще это сделать, поэтому и начал с самого сложного для меня)
Хардкод на Java с нуля: поехали ?
Сразу скажу, что «код», который будет представлен далее, по большей части для прототипирования. Не стоит его оценивать или считать за образец, он может быть полезен только тем, кому будет интересно повторить мой опыт, а не для «искателей чужих ошибок»)) В коде вы можете увидеть огромные закоментированные свалки, не обращайте внимания, т. к. у меня был выбор либо публиковать код, либо нет. Я никак не форматировал его и не подготавливал к выводу «в свет» и потому не стеснялся оставлять там костыли и другие неприятные вещи, например, принты для дебага. Однако кое‑что я всё‑таки форматнул, но эту особенность заметят только самые внимательные =) Если фрагмент с кодом называется «Кодопомойка» — значит это просто свалка функций. Может быть интересно тем, кто хочет сам разобраться в том, как технически моя лабуда работает. «Кодопомойка+» будет означать, что к каким‑то функциям я заботливо добавил парочку комментариев. |
Про GitHub...
Ээээ.... Что же мне сказать по поводу GitHub... Вернее... Про публикацию моего всего текущего кода на нём... Как бы по-мягче... Сразу скажу, кое-что таки да я выгрузил.
Скажем так. Если эта статья делалась около месяца — то, если бы я выпускал полный проект на GitHub — код бы там вышел через 10 лет, а статья — никогда. Ну камон... Мой разрозненный бредокод — немного не то, что принято выкладывать на такие площадки, как GitHub. В то же время, мне очень хотелось этим всем поделиться, не прикладывая ещё более титанических усилий и не нарушая мой «тунеядский» ритм работы... Для GitHub, по-хорошему, нужна четкая структура проекта, а я не хотел заморачиваться, так как у меня часть системы на Docker и проект PyСharm на винде, в которых удобно разрабатывать и дорабатывать, а виртуальная стримерша — это такая штука, которую постоянно надо дорабатывать. Несмотря на месяцы работы, я пока даже близко не подошёл к моменту, когда можно «закончить» разработку какой‑то системы и выдать «релиз». По крайней мере, мне так кажется... Но, в будущем, быть может, я захочу привести проект к более‑менее «публикационному» для GitHub виду (например, запихаю всё на Docker).
Да, можно было бы выгрузить некоторые обособленные модули — например, тот же решатор капч, или текущую версию моего форка AltoClef'a. Вот это — уже совсем другой разговор, и для портфолио может быть полезно! Они уже лежат на моём гите, вот только от этого вряд ли поменяется тот факт, что там всё ещё свалко-безобразный код...
Дорабатываем мод для игры
Больше всего я сомневался насчет своих навыков по части разработки Java (их не было), а из ООП я серьезно мутил что‑то только на C#, поэтому первым делом решено было допилить нужный мне мод для Minecraft! Как мы выше условились, за мод берём AltoClef. Открывам IntelliJ Idea Community, скачиваем репу и поехали в путь‑дорогу. Ну или поползли, в моем случае... После недели маянья с настройкой и запуска в gradle (как никак у человека опыта в Джаве примерно ноль), кое‑как я добился сборки и запуска проекта по исходникам.
Код и всё такое (Java)
Заходим в гости к нашему проекту AltoClef в IntelliJ. Смотрим, как тут всё устроено.
Как видим, тут у нас реализована task‑система и в теории с написанием скрипта автоматической игры не должно возникнуть сложностей. Красным выделено то, что насоздавал я. Итак, создаем класс таска, который будет реализовывать, я покажу на примере SkyWarsTask
. Далее пишем туда код. Код я писал на основе TerminatorTask
, поэтому не удивляйтесь свалке комментариев. После написания кода класса нужно ещё добавить соответствующую команду в обработчик команд и привязать к ней созданный класс таска, но эту часть я сюда не вставлял, потому что это не особо интересно.
Кодопомойка SkyWarsTask.java
package adris.altoclef.tasks.stupid;
import adris.altoclef.AltoClef;
import adris.altoclef.Debug;
import adris.altoclef.TaskCatalogue;
import adris.altoclef.eventbus.EventBus;
import adris.altoclef.eventbus.Subscription;
import adris.altoclef.eventbus.events.BlockPlaceEvent;
import adris.altoclef.tasks.container.LootContainerTask;
import adris.altoclef.tasks.entity.KillPlayerTask;
import adris.altoclef.tasks.entity.ShootArrowSimpleProjectileTask;
import adris.altoclef.tasks.misc.EquipArmorTask;
import adris.altoclef.tasks.movement.PickupDroppedItemTask;
import adris.altoclef.tasks.movement.SearchChunksExploreTask;
import adris.altoclef.tasks.movement.ThrowEnderPearlSimpleProjectileTask;
import adris.altoclef.tasks.resources.CollectFoodTask;
import adris.altoclef.tasksystem.Task;
import adris.altoclef.ui.MessagePriority;
import adris.altoclef.util.ItemTarget;
import adris.altoclef.util.helpers.*;
import adris.altoclef.util.time.TimerGame;
import baritone.api.utils.input.Input;
import net.minecraft.block.Block;
import net.minecraft.block.BlockState;
import net.minecraft.block.Blocks;
import net.minecraft.entity.Entity;
import net.minecraft.entity.ItemEntity;
import net.minecraft.entity.effect.StatusEffects;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.item.Item;
import net.minecraft.item.Items;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.ChunkPos;
import net.minecraft.util.math.Vec3d;
import net.minecraft.util.math.Vec3i;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
* SlotHandler 39 timer override изменил
*/
public class SkyWarsTask extends Task {
private static final int FEAR_SEE_DISTANCE = 30;
private static final int FEAR_DISTANCE = 20;
private static final int RUN_AWAY_DISTANCE = 80;
private static final int MIN_BUILDING_BLOCKS = 10;
private static final int PREFERRED_BUILDING_BLOCKS = 60;
private static Item[] GEAR_TO_COLLECT = new Item[]{
Items.DIAMOND_PICKAXE, Items.DIAMOND_SHOVEL, Items.DIAMOND_SWORD, Items.WATER_BUCKET
};
private final Task _prepareDiamondMiningEquipmentTask = TaskCatalogue.getSquashedItemTask(
new ItemTarget(Items.IRON_PICKAXE, 3), new ItemTarget(Items.IRON_SWORD, 1)
);
private final Task _foodTask = new CollectFoodTask(80);
private final TimerGame _runAwayExtraTime = new TimerGame(10);
private final Predicate<PlayerEntity> _canTerminate;
private final ScanChunksInRadius _scanTask;
private final TimerGame _funnyMessageTimer = new TimerGame(10);
private final TimerGame _performExtraActionsTimer = new TimerGame(2.5);
private Vec3d _closestPlayerLastPos;
private Vec3d _closestPlayerLastObservePos;
private double _closestDistance;
private Task _runAwayTask;
private String _currentVisibleTarget;
private boolean _forceWait = false;
private boolean _isEatingStrength = false;
private boolean _isEatingGapple = false;
private final TimerGame _eatingGappleTimer = new TimerGame(3);
private Task _armorTask;
private Task _shootArrowTask;
private Task _lootTask;//new CataloguedResourceTask(new ItemTarget(Items.ENDER_PEARL));
private Task _pickupTask;
private boolean _finishOnKilled = false;
private static Item[] _itemsToLoot = ItemHelper.DIAMOND_TOOLS;
private List<Item> lootableItems(AltoClef mod) {
List<Item> lootable = new ArrayList<>();
lootable.addAll(ArmorAndToolsNeeded(mod));
//lootable.addAll(Arrays.stream(ItemHelper.NETHERITE_TOOLS).toList());
//lootable.addAll(Arrays.stream(ItemHelper.DIAMOND_TOOLS).toList());
//lootable.addAll(Arrays.stream(ItemHelper.HelmetsTopPriority).toList());
//lootable.addAll(Arrays.stream(ItemHelper.ChestplatesTopPriority).toList());
//lootable.addAll(Arrays.stream(ItemHelper.LeggingsTopPriority).toList());
//lootable.addAll(Arrays.stream(ItemHelper.BootsTopPriority).toList());
lootable.addAll(Arrays.stream(ItemHelper.PLANKS).toList());
lootable.add(Items.GOLDEN_APPLE);
lootable.add(Items.ENCHANTED_GOLDEN_APPLE);
lootable.add(Items.GOLDEN_CARROT);
lootable.add(Items.STONE);
lootable.add(Items.BOW);
lootable.add(Items.ARROW);
lootable.add(Items.GUNPOWDER);
lootable.add(Items.ENDER_PEARL);
if (!mod.getItemStorage().hasItemInventoryOnly(Items.WATER_BUCKET)) {
lootable.add(Items.WATER_BUCKET);}
return lootable;
}
private Subscription<BlockPlaceEvent> _blockPlaceSubscription;
public SkyWarsTask(BlockPos center, double scanRadius, Predicate<PlayerEntity> canTerminate, boolean FinishOnKilled) {
_canTerminate = canTerminate;
_finishOnKilled = FinishOnKilled;
_scanTask = new ScanChunksInRadius(center, scanRadius);
}
public SkyWarsTask(BlockPos center, double scanRadius, boolean FinishOnKilled) {
this(center, scanRadius, accept -> true, FinishOnKilled);
}
private static final Block[] TO_SCAN = Stream.concat(Arrays.stream(new Block[]{Blocks.CHEST, Blocks.TRAPPED_CHEST, Blocks.BARREL}), Arrays.stream(ItemHelper.itemsToBlocks(ItemHelper.SHULKER_BOXES))).toArray(Block[]::new);
@Override
protected void onStart(AltoClef mod) {
//Debug.logMessage("стейт = "+mod.getInfoSender().getState());
mod.getInfoSender().setState(String.valueOf(mod.getItemStorage().hasItem(Items.ENDER_PEARL)));
mod.getBehaviour().push();
mod.getBlockTracker().trackBlock(TO_SCAN);
mod.getBehaviour().setForceFieldPlayers(true);
//mod.getExtraBaritoneSettings()
_blockPlaceSubscription = EventBus.subscribe(BlockPlaceEvent.class, evt -> {
OnBlockPlace(mod,evt.blockPos,evt.blockState);
});
//Debug.logMessage("мдааа");
//AddNearestPlayerToFriends(mod,10);
}
protected void OnBlockPlace(AltoClef mod, BlockPos blockPos, BlockState blockState){
if(this._forceWait == false && mod.getClientBaritone().getCustomGoalProcess().isActive() &
mod.getPlayer().isSneaking() &
mod.getPlayer().getBlockPos().isWithinDistance(new Vec3i(blockPos.getX(),blockPos.getY(),blockPos.getZ()),3) ){
//mod.getClientBaritone().getGetToBlockProcess().
//Debug.logMessage("!!Блок поставил я!");
new Thread(() ->{
int ping = 100;
//try{
// ping = mod.getPlayer().networkHandler.getPlayerListEntry(mod.getPlayer().getUuid()).getLatency();}
//catch (NullPointerException e){e.printStackTrace(); ping = 500;}
//Goal goal = mod.getClientBaritone().getCustomGoalProcess().getGoal();
//boolean oldval = mod.getClientBaritoneSettings().allowPlace.value;
//mod.getClientBaritoneSettings().
//mod.getClientBaritone().getCustomGoalProcess().setGoal(new GoalBlock(0,0,0));
//mod.getClientBaritoneSettings().allowPlace.value = false;
this._forceWait = true;
//if(mod.getClientBaritone().getPathingBehavior().isPathing()) # БЫЛО ДО ЭТОГО!
//mod.getClientBaritone().getPathingBehavior().forceCancel();
//mod.getClientBaritone().getInputOverrideHandler().setInputForceState(Input.SNEAK,true);
//mod.getClientBaritone().getInputOverrideHandler().setInputForceState(Input.MOVE_FORWARD,true);
mod.getInputControls().hold(Input.SNEAK);
mod.getInputControls().hold(Input.MOVE_FORWARD);
mod.getInputControls().hold(Input.CLICK_RIGHT);
//Debug.logMessage("Остановка.. ");
//mod.getMobDefenseChain()._doingFunkyStuff =true;
sleepSec(0.4);
//mod.getClientBaritone().getInputOverrideHandler().setInputForceState(Input.SNEAK,false);
//mod.getClientBaritone().getInputOverrideHandler().setInputForceState(Input.MOVE_FORWARD,false);
mod.getInputControls().release(Input.CLICK_RIGHT);
mod.getInputControls().release(Input.SNEAK);
mod.getInputControls().release(Input.MOVE_FORWARD);
//mod.getPlayer().
Debug.logMessage("Блок поставила я! "+WorldHelper.isAir(mod,blockPos) + " ыы пинг "+ping);
if(WorldHelper.isAir(mod,blockPos)){
Debug.logMessage("Блок на позиции "+blockPos + " не поставился! пинг "+ping);
//for(int i = 0;i<10;i++){
//LookHelper.SmoothLookDirectionaly(mod,0.0015f);
mod.getInputControls().hold(Input.SNEAK);
mod.getInputControls().hold(Input.MOVE_BACK);
mod.getInputControls().hold(Input.CLICK_RIGHT);
sleepSec(6);
//sleepSec(3+((30+ping)*2)/1000);
mod.getInputControls().release(Input.MOVE_BACK);
sleepSec(1);
mod.getInputControls().release(Input.SNEAK);
mod.getInputControls().release(Input.CLICK_RIGHT);
//}
//sleepSec(4);
}
//mod.getBehaviour().
//mod.getMobDefenseChain()._doingFunkyStuff =false;
this._forceWait = false;
//mod.getClientBaritoneSettings().allowPlace.value = oldval;
//if(mod.getClientBaritone().getCustomGoalProcess().isActive()){
// mod.getClientBaritone().getCustomGoalProcess().setGoalAndPath(goal);
//}
//try{
// mod.getClientBaritone().getCustomGoalProcess().wait(200);
//} catch (InterruptedException e) {
// e.printStackTrace();
//}
//mod.getClientBaritone().getBuilderProcess().pause();
//sleepSec(0.5);
//mod.getClientBaritone().getBuilderProcess().resume();
}).start();
}
}
private BlockPos _lastLootPos;
@Override
protected Task onTick(AltoClef mod){
Optional<Entity> closest = mod.getEntityTracker().getClosestEntity(mod.getPlayer().getPos(), toPunk -> shouldPunk(mod, (PlayerEntity) toPunk), PlayerEntity.class);
boolean TargetIsNear = false;
if(InputHelper.isKeyPressed(71) && mod.getClientBaritone().getPathingBehavior().estimatedTicksToGoal().isPresent())
Debug.logMessage("Эвристика **стика "+mod.getClientBaritone().getPathingBehavior().estimatedTicksToGoal().get());
if (closest.isPresent()) {
_closestPlayerLastPos = closest.get().getPos();
_closestPlayerLastObservePos = mod.getPlayer().getPos();
_closestDistance = _closestPlayerLastPos.distanceTo(_closestPlayerLastObservePos);
if (_closestDistance<=8 & mod.getEntityTracker().isEntityReachable(closest.get())) TargetIsNear = true;
//Debug.logMessage("дистанция"+_closestDistance);
}
int ping = 100;
//try{ping = mod.getPlayer().networkHandler.getPlayerListEntry(mod.getPlayer().getUuid()).getLatency();}
//catch (NullPointerException e){e.printStackTrace(); ping = 500;}
//if(InputHelper.isKeyPressed(71)){
// Debug.logMessage("Ping = "+ping);//"PlusY "+PlusY + " Y "+_targetRotation.getPitch());}
//}
if(ping>499){
setDebugState("ИСПЫТЫВАЕМ ЛЮТЫЙ ПИНГ = "+ping+"!!! Ожидаем окончания этого ..");
return null;}
//Predicate<BlockPos> validContainer = blockPos -> {
// if(!WorldHelper.isUnopenedChest(mod, blockPos)|| !mod.getPlayer().getBlockPos().isWithinDistance(blockPos, 15))//!WorldHelper.isUnopenedChest(mod, blockPos)||
// return false;
// else {
// return true;
// }
//};
if(_forceWait && !TargetIsNear){
//Debug.logMessage("Ждемс...");
return null;}
if (shouldForce(mod, _shootArrowTask)) {
return _shootArrowTask;
}
if(!TargetIsNear) {
//ОДЕВАЕМСЯ КАК ПОЛОЖЕНО!!!
//Item[] helmetsTopPriority = new Item[] {Items.NETHERITE_HELMET, Items.DIAMOND_HELMET, Items.IRON_HELMET, Items.CHAINMAIL_HELMET, Items.GOLDEN_HELMET, Items.LEATHER_HELMET};
//if(InputHelper.isKeyPressed(71))Debug.logMessage("hasHelmetLevel ="+hasHelmetLevel+" helmetLevel="+helmetLevel);//"PlusY "+PlusY + " Y "+_targetRotation.getPitch());}
if (shouldForce(mod, _armorTask)) {
return _armorTask;
}
//if (shouldForce(mod, _pickupTask)) {
// return _pickupTask;
//}
boolean reachableLootCont = true;
if(_lastLootPos!=null) reachableLootCont = WorldHelper.canReach(mod,_lastLootPos);
if (reachableLootCont && shouldForce(mod, _lootTask)) {
return _lootTask;
}
if(_isEatingStrength)
_isEatingStrength = false;
//ЮЗАТЬ СМЕСЬ СИЛЫ
if(!mod.getPlayer().hasStatusEffect(StatusEffects.STRENGTH)&&mod.getItemStorage().hasItem(Items.GUNPOWDER)){
//mod.getItemStorage().getItem
if(LookHelper.tryAvoidingInteractable(mod,true)) {
setDebugState("Найдена смесь силы; надо понюхать");
mod.getSlotHandler().forceEquipItem(new Item[]{Items.GUNPOWDER}); //"true" because it's food
mod.getInputControls().hold(Input.CLICK_RIGHT);
//mod.getExtraBaritoneSettings().setInteractionPaused(true);
mod.getInputControls().release(Input.CLICK_RIGHT);
//mod.getExtraBaritoneSettings().setInteractionPaused(false);
_isEatingStrength = true;
}else{
setDebugState("Нюхаем смесь силы: меняем угол обзора чтобы не интерактить ни с какими блоками");
}
return null;
}
//
//ЖРАТЬ ЯБЛОЧКИ
boolean NeedEatGapple = !mod.getPlayer().hasStatusEffect(StatusEffects.ABSORPTION) || (mod.getPlayer().getHealth()<18&&_eatingGappleTimer.getDuration()>6);
if(NeedEatGapple&&mod.getItemStorage().hasItemInventoryOnly(Items.GOLDEN_APPLE,Items.ENCHANTED_GOLDEN_APPLE)){
if(LookHelper.tryAvoidingInteractable(mod) && !_isEatingGapple) {
setDebugState("Есть яблоко, почему бы не пожрать..");
//mod.getSlotHandler().forceEquipSlot(new Slot(0,0,0,0));
mod.getSlotHandler().forceEquipItem(new Item[]{Items.GOLDEN_APPLE,Items.ENCHANTED_GOLDEN_APPLE},true);//,true); //"true" because it's food
mod.getInputControls().hold(Input.CLICK_RIGHT);
//mod.getSlotHandler().wait();
mod.getExtraBaritoneSettings().setInteractionPaused(true);
_eatingGappleTimer.reset();
_isEatingGapple= true;
}
else{
if(_isEatingGapple && _eatingGappleTimer.elapsed()){
_isEatingGapple= false;
setDebugState("Яблоко не съелось! Попытка 2!");
}else{
setDebugState("Жрем геплы: меняем угол обзора чтобы не интерактить с сущностями");
}
}
return null;
}else{
if(_isEatingGapple){
mod.getInputControls().release(Input.CLICK_RIGHT);
mod.getExtraBaritoneSettings().setInteractionPaused(false);
_isEatingGapple = false;}
}
//if(_pickupTask.)
//if(mod.getPlayer().getEf){}
//ШЛЕМ
int armorEquipNeed = IsArmorNeededToEquip(mod,ItemHelper.HelmetsTopPriority);
if (armorEquipNeed != -1){
_armorTask = new EquipArmorTask(true, Arrays.stream(ItemHelper.HelmetsTopPriority).toList().get(armorEquipNeed));
return _armorTask;
}
//ЧЕСТПЛЕЙТ
armorEquipNeed = IsArmorNeededToEquip(mod,ItemHelper.ChestplatesTopPriority);
if (armorEquipNeed != -1){
_armorTask = new EquipArmorTask(true, Arrays.stream(ItemHelper.ChestplatesTopPriority).toList().get(armorEquipNeed));
return _armorTask;
}
//ПЕНТС
armorEquipNeed = IsArmorNeededToEquip(mod,ItemHelper.LeggingsTopPriority);
if (armorEquipNeed != -1){
_armorTask = new EquipArmorTask(true, Arrays.stream(ItemHelper.LeggingsTopPriority).toList().get(armorEquipNeed));
return _armorTask;
}
//БУТС
armorEquipNeed = IsArmorNeededToEquip(mod,ItemHelper.BootsTopPriority);
if (armorEquipNeed != -1){
_armorTask = new EquipArmorTask(true, Arrays.stream(ItemHelper.BootsTopPriority).toList().get(armorEquipNeed));
return _armorTask;
}
//if (!StorageHelper.isArmorEquipped(mod, topHelmet )) {
// if (mod.getItemStorage().hasItem(topHelmet)) {
// _armorTask = new EquipArmorTask(true, topHelmet);
// return _armorTask;
// }
//}
//ТЕПЕРЬ ЛУТАЕМ СУНДУЧАРЫ!!!
//Optional<BlockPos> closestCont = mod.getBlockTracker().getNearestTracking(validContainer,TO_SCAN);
Optional<BlockPos> closestCont = mod.getBlockTracker().getNearestTracking(
blockPos -> WorldHelper.isUnopenedChest(mod, blockPos) &&
mod.getPlayer().getBlockPos().isWithinDistance(blockPos, 10)&&
WorldHelper.canReach(mod,blockPos), Blocks.CHEST) ;
if (closestCont.isPresent() && WorldHelper.canReach(mod,closestCont.get()) && TimersHelper.CanChestInteract()) {
setDebugState("Поиск ресурсов -> контейнеры:");
_lastLootPos = closestCont.get();
_lootTask = new LootContainerTask(closestCont.get(), lootableItems(mod));
//_lootTask = new MineAndCollectTask(new ItemTarget(Items.CHEST), new Block[]{Blocks.CHEST}, MiningRequirement.HAND);
return _lootTask;
}
//ПИКАЕМ ДРОП
for (Item check : lootableItems(mod)) {
if (mod.getEntityTracker().itemDropped(check)) {
Optional<ItemEntity> closestEnt = mod.getEntityTracker().getClosestItemDrop(
ent -> mod.getPlayer().getPos().isInRange(ent.getEyePos(), 10),check);
//
if(closestEnt.isPresent()) {
_pickupTask = new PickupDroppedItemTask(new ItemTarget(check), true);
return _pickupTask;
}
}
}
if(closest.isPresent() && ShouldBow(mod,closest.get())){
_shootArrowTask = new ShootArrowSimpleProjectileTask(closest.get());
return _shootArrowTask;
}
}else{
if(_isEatingGapple){
mod.getInputControls().release(Input.CLICK_RIGHT);
mod.getExtraBaritoneSettings().setInteractionPaused(false);
_isEatingGapple = false;}
}
if(closest.isPresent()){
setDebugState("УНИЧТОЖИТЬ");
PlayerEntity entity = (PlayerEntity) closest.get();
if(mod.getPlayer().distanceTo(entity)>10 && LookHelper.cleanLineOfSight(entity.getPos(),100)) {
if (mod.getItemStorage().getItemCount(Items.ENDER_PEARL) > 2){
return new ThrowEnderPearlSimpleProjectileTask(entity.getBlockPos().add(0, -0.5, 0));}
else if(ShouldBow(mod, entity)){
_shootArrowTask = new ShootArrowSimpleProjectileTask(entity);
return _shootArrowTask;
}
}
//tryDoFunnyMessageTo(mod, (PlayerEntity) entity);
return new KillPlayerTask(entity.getName().getString());
}
setDebugState("Поиск сущностей...");
_currentVisibleTarget = null;
if (_scanTask.failedSearch()) {
Debug.logMessage("Перегрузка поиска, восстановление...");
_scanTask.resetSearch(mod);
}
return _scanTask;
}
private Optional<BlockPos> locateClosestUnopenedChest(AltoClef mod) {
//if (WorldHelper.getCurrentDimension() != Dimension.OVERWORLD) {
// return Optional.empty();
//}
return mod.getBlockTracker().getNearestTracking(blockPos -> mod.getPlayer().getBlockPos().isWithinDistance(blockPos, 15), Blocks.CHEST);
//mod.getBlockTracker().getNearestTracking(blockPos -> WorldHelper.isUnopenedChest(mod, blockPos) && mod.getPlayer().getBlockPos().isWithinDistance(blockPos, 15), Blocks.CHEST);
}
@Override
protected void onStop(AltoClef mod, Task interruptTask) {
mod.getBehaviour().pop();
mod.getBlockTracker().stopTracking(TO_SCAN);
EventBus.unsubscribe(_blockPlaceSubscription);
}
@Override
protected boolean isEqual(Task other) {
return other instanceof SkyWarsTask;
}
@Override
protected String toDebugString() {
return "Активна игра в SkyWars";
}
private boolean ShouldBow(AltoClef mod, Entity target){
if(LookHelper.shootReady(mod,target)&&mod.getItemStorage().hasItem(Items.BOW) && (mod.getItemStorage().hasItem(Items.ARROW) || mod.getItemStorage().hasItem(Items.SPECTRAL_ARROW)))
{
return true; }else {return false;}
}
private List<Item> ArmorAndToolsNeeded(AltoClef mod) {
List<Item> Needed = new ArrayList<>();
//БРОНЯ
Needed.addAll(ItemsNeeded(mod,ItemHelper.HelmetsTopPriority));
Needed.addAll(ItemsNeeded(mod,ItemHelper.ChestplatesTopPriority));
Needed.addAll(ItemsNeeded(mod,ItemHelper.LeggingsTopPriority));
Needed.addAll(ItemsNeeded(mod,ItemHelper.BootsTopPriority));
//ИНСТРУМЕНТЫ
Needed.addAll(ItemsNeeded(mod,ItemHelper.SwordsTopPriority));
Needed.addAll(ItemsNeeded(mod,ItemHelper.AxesTopPriority));
Needed.addAll(ItemsNeeded(mod,ItemHelper.PickaxesTopPriority));
Needed.addAll(ItemsNeeded(mod,ItemHelper.ShovelsTopPriority));
Needed.addAll(ItemsNeeded(mod,ItemHelper.HoesTopPriority));
//Needed.addAll(ItemsNeeded(mod,ItemHelper.Tool));
return Needed;
}
private List<Item> ItemsNeeded(AltoClef mod,Item[] PriorityCheckArr){
List<Item> NeededItems = new ArrayList<>();
//NeededItems.add(Items.GOLDEN_APPLE);
int level = GetHighestItemLevel(mod,PriorityCheckArr);
int iii = 0;
for (Item i : PriorityCheckArr){
if(iii<level){
NeededItems.add(Arrays.stream(PriorityCheckArr).toList().get(iii));
}
iii++;
}
//NeededItems.addAll(Arrays.stream(ItemHelper.NETHERITE_TOOLS).toList());
return NeededItems;
}
private int GetHighestItemLevel(AltoClef mod,Item[] PriorityCheckArr){
int iii = 0;
int Level = 7;
for(Item i : PriorityCheckArr) {
if (StorageHelper.isArmorEquipped(mod, i) || mod.getItemStorage().hasItem(i)) {
if(Level>iii)
Level = iii;
}
iii++;
}
return Level;
}
private int IsArmorNeededToEquip(AltoClef mod, Item[] ArmorsTopPriority){
int iii = 0;
int Level = -1;
int hasLevel = 7;
//if()
for(Item armorItem : ArmorsTopPriority){
if (StorageHelper.isArmorEquipped(mod, armorItem )) {
Level = iii;
}
if (mod.getItemStorage().hasItem(armorItem)) {
if(hasLevel>iii)
hasLevel = iii;
}
iii++;
}
if(Level==-1)Level=7;
if (hasLevel<Level){
return hasLevel;
}else{ return -1;}
}
private boolean isReadyToPunk(AltoClef mod) {
if (mod.getPlayer().getHealth() <= 5) return false; // We need to heal.
return StorageHelper.isArmorEquippedAll(mod, ItemHelper.DIAMOND_ARMORS) && mod.getItemStorage().hasItem(Items.DIAMOND_SWORD);
}
private boolean shouldPunk(AltoClef mod, PlayerEntity player) {
if (player == null || player.isDead() || !player.isAlive()) return false;
if (player.isCreative() || player.isSpectator()) return false;
//if (!WorldHelper.canReach(mod,player.getBlockPos())) return false;
//mod.getEntityTracker().getCloseEntities().
return !mod.getButler().isUserAuthorized(player.getName().getString());// && _canTerminate.test(player);
}
private void tryDoFunnyMessageTo(AltoClef mod, PlayerEntity player) {
if (_funnyMessageTimer.elapsed()) {
if (LookHelper.seesPlayer(player, mod.getPlayer(), 80)) {
String name = player.getName().getString();
if (_currentVisibleTarget == null || !_currentVisibleTarget.equals(name)) {
_currentVisibleTarget = name;
_funnyMessageTimer.reset();
String funnyMessage = getRandomFunnyMessage();
mod.getMessageSender().enqueueWhisper(name, funnyMessage, MessagePriority.ASAP);
}
}
}
}
private String getRandomFunnyMessage() {
return "Советую спрятаться, кид";
}
private static boolean shouldForce(AltoClef mod, Task task) {
return task != null && task.isActive() && !task.isFinished(mod);
}
private class ScanChunksInRadius extends SearchChunksExploreTask {
private final BlockPos _center;
private final double _radius;
public ScanChunksInRadius(BlockPos center, double radius) {
_center = center;
_radius = radius;
}
@Override
protected boolean isChunkWithinSearchSpace(AltoClef mod, ChunkPos pos) {
double cx = (pos.getStartX() + pos.getEndX()) / 2.0;
double cz = (pos.getStartZ() + pos.getEndZ()) / 2.0;
double dx = _center.getX() - cx,
dz = _center.getZ() - cz;
return dx * dx + dz * dz < _radius * _radius;
}
@Override
protected ChunkPos getBestChunkOverride(AltoClef mod, List<ChunkPos> chunks) {
// Prioritise the chunk we last saw a player in.
if (_closestPlayerLastPos != null) {
double lowestScore = Double.POSITIVE_INFINITY;
ChunkPos bestChunk = null;
for (ChunkPos toSearch : chunks) {
double cx = (toSearch.getStartX() + toSearch.getEndX() + 1) / 2.0, cz = (toSearch.getStartZ() + toSearch.getEndZ() + 1) / 2.0;
double px = mod.getPlayer().getX(), pz = mod.getPlayer().getZ();
double distanceSq = (cx - px) * (cx - px) + (cz - pz) * (cz - pz);
double pdx = _closestPlayerLastPos.getX() - cx, pdz = _closestPlayerLastPos.getZ() - cz;
double distanceToLastPlayerPos = pdx * pdx + pdz * pdz;
Vec3d direction = _closestPlayerLastPos.subtract(_closestPlayerLastObservePos).multiply(1, 0, 1).normalize();
double dirx = direction.x, dirz = direction.z;
double correctDistance = pdx * dirx + pdz * dirz;
double tempX = dirx * correctDistance,
tempZ = dirz * correctDistance;
double perpendicularDistance = ((pdx - tempX) * (pdx - tempX)) + ((pdz - tempZ) * (pdz - tempZ));
double score = distanceSq + distanceToLastPlayerPos * 0.6 - correctDistance * 2 + perpendicularDistance * 0.5;
if (score < lowestScore) {
lowestScore = score;
bestChunk = toSearch;
}
}
return bestChunk;
}
return super.getBestChunkOverride(mod, chunks);
}
@Override
protected boolean isEqual(Task other) {
if (other instanceof ScanChunksInRadius scan) {
return scan._center.equals(_center) && Math.abs(scan._radius - _radius) <= 1;
}
return false;
}
@Override
protected String toDebugString() {
return "Сканирование территории...";
}
}
private static void sleepSec(double seconds) {
try {
Thread.sleep((int) (1000 * seconds));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
После того, как разобрались со скриптом, сделаем функцию для связи с главным Python при помощи Py4j. Для начала реализуем интерфейс PythonCallback для Py4j обратного вызова методов Python из Java‑части.
Кодопомойка PythonCallback.java
package adris.altoclef;
import py4j.GatewayServer;
import py4j.PythonClient;
import py4j.ClientServer;
import java.util.Map;
public interface PythonCallback {
public Boolean isStarted();
public String onChatMessage(String s);
public Map<String,String> onVerifedChat(Map<String,String> s);
public Map<String,String> onUpdateServerInfo(Map<String,String> s);
public void onDeath(String s);
public void onKill(String s);
public void onDamage(float s);
public void onCaptchaSolveRequest(byte[] image_bytes);
}
Теперь сделаем класс с Java‑функциями, которые можно будет вызывать из Python‑части. Внедряем функционала по максиму, чтобы можно было получить как список всех задач бота, так и координаты, на которых стоит игрок. Дополнительно в коде я внедрил функцию определения «экранного» расстояния между целью Baritone (например, при задаче подойти к определенному блоку или сущности, мод устанавливает этот блок как цель в Baritone) и курсором с помощью расчета угла поворота до этой цели. Дальше нам это понадобится при работе с VTube Studio.
Кодопомойка Py4jEntryPoint.java
package adris.altoclef;
import adris.altoclef.butler.WhisperChecker;
import adris.altoclef.chains.DeathMenuChain;
import adris.altoclef.tasksystem.Task;
import adris.altoclef.ui.MessagePriority;
import adris.altoclef.util.helpers.BaritoneHelper;
import adris.altoclef.util.helpers.LookHelper;
import adris.altoclef.util.helpers.WorldHelper;
import adris.altoclef.util.time.TimerGame;
import adris.altoclef.util.time.TimerReal;
import baritone.api.pathing.calc.IPath;
import baritone.api.pathing.goals.Goal;
import baritone.api.utils.BetterBlockPos;
import baritone.api.utils.Rotation;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.option.Perspective;
import net.minecraft.entity.Entity;
import net.minecraft.entity.FallingBlockEntity;
import net.minecraft.item.ItemStack;
import net.minecraft.item.Items;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Vec3d;
import org.lwjgl.system.CallbackI;
import py4j.PythonClient;
import py4j.ClientServer;
import py4j.GatewayServer;
import java.util.*;
public class Py4jEntryPoint {
AltoClef _mod;
PythonCallback _cb;
public Py4jEntryPoint(AltoClef mod)
{
_mod = mod;
resetValues();
}
public void resetValues(){
CentralGameInfoDict.put("server", "universal");
CentralGameInfoDict.put("serverMode", "survival");
CentralGameInfoDict.put("chatType", "lobby");
//if(DeathMenuChain.ServerIp!=null)
// if(!DeathMenuChain.ServerIp.isEmpty())
// CentralGameInfoDict.put("server", DeathMenuChain.ServerIp);
}
public void setPerspective(int perspectiveNum) {
//Perspective perspective = Perspective.values()[perspectiveNum] быстрое решение но нужна проверка
Perspective perspective = Perspective.FIRST_PERSON;
switch (perspectiveNum){
case 0:
perspective = Perspective.FIRST_PERSON;
break;
case 1:
perspective = Perspective.THIRD_PERSON_BACK;
break;
case 2:
perspective = Perspective.THIRD_PERSON_FRONT;
break;
default:
Debug.logMessage("запрошена неизвестная перспектива: "+perspectiveNum);
}
MinecraftClient.getInstance().options.setPerspective(perspective);
}
//public Map<String,String> getIngameInfo(){
// Map<String,String> result_dict = new HashMap<>();
// result_dict.put("task_chain",getTaskChainString());
// result_dict.put("ground_block",getGroundBlock());
// result_dict.put("held_item",getHeldItem());
// return result_dict;
//}
public String getTaskChainString (){
String tasks_string = "Ничего не происходит";
try {
if (_mod.getTaskRunner().getCurrentTaskChain() != null) {
List<Task> tasks = _mod.getTaskRunner().getCurrentTaskChain().getTasks();
if (tasks.size() > 0) {
tasks_string = "";
int i = 0;
for (Task task : tasks) {
tasks_string += (i+1)+") "+task.toString();
if(i<tasks.size()-1){tasks_string+="\n";}
i++;
}
}
}
}catch (Exception e) {tasks_string = "Ошибка при получении списка игровых подзадач! Скрипт сломался!";}
return tasks_string;
}
public String getGroundBlock (){
if (AltoClef.inGame() && _mod.getPlayer()!=null && _mod.getWorld() != null) {
//MinecraftClient.getInstance().options.setPerspective(Perspective.FIRST_PERSON);
//MinecraftClient.getInstance().options.setPerspective(Perspective.THIRD_PERSON_BACK); //ЗАДНИЦА
//MinecraftClient.getInstance().options.setPerspective(Perspective.THIRD_PERSON_FRONT); //ВСЕМ ПРИВЕТ
String blockName = WorldHelper.getGroundBlockName(_mod);
if(_mod.getPlayer().isOnGround() && blockName.equals("воздух")){
return "земля";
}else{
return blockName;
}
}else{
return "пустота";
}
}
public String getHeldItem(){
if (AltoClef.inGame() && _mod.getPlayer()!=null && _mod.getPlayer().getItemsHand()!=null) {
for (ItemStack item : _mod.getPlayer().getItemsHand()){
if(item.getItem()!=null){
String itemName = item.getItem().getName().getString().toLowerCase();
if(!itemName.equals("воздух")){
if(item.hasCustomName()) {
String itemCustomName = item.getName().getString().toLowerCase();
return itemName+" (с названием " + itemCustomName+")";
}
//Debug.logMessage("ITEM CUSTOM NAME = "+itemCustomName);
return itemName;
}
}
}
return "ничего";
}else{
return "ничего";
}
}
public String getInfo(){
String result = "";
for (String value : CentralGameInfoDict.values()){
if(!value.isBlank()){
result+=value+" ";
}
}
if(callbackstarted)
result+="CB=ON";
return result.strip();
}
public String getInfo(String key){return getInfo(key,"");}
public String getInfo(String key, String defolt){
return CentralGameInfoDict.getOrDefault(key, defolt);
}
public void InitPythonCallback(){
_cb = (PythonCallback) _mod.getGateway().getPythonServerEntryPoint(new Class[] {PythonCallback.class});
}
boolean callbackstarted = false;
public boolean IsCallbackServerStarted(){
boolean result = false;
try {
_cb.isStarted();
result = true;
}catch (Exception e) {}
callbackstarted = result;
return result;
}
String _state = "starting";
public String saayHellooo(String name) {
return "Hello, " + name + "!" + Items.SOUL_SAND.getName().getString();
}
public String getState(){
return _state;
}
public void setState(String state){
_state = state;
}
public static boolean inGame(){
return AltoClef.inGame();
}
public void onStrongChatMessage(WhisperChecker.MessageResult message){
if(IsCallbackServerStarted()) {
Map<String,String> messageDict = new HashMap<>();
//if()
messageDict.put("user",message.from);
messageDict.put("msg",message.message);
if(message.clan != null) messageDict.put("clan",message.clan);
if(message.team != null) messageDict.put("team",message.team);
if(message.starter_prefix != null) messageDict.put("pre",message.starter_prefix);
if(message.rank != null) messageDict.put("rank",message.rank);
if(message.serverExactPrediction != null) messageDict.put("precision",message.serverExactPrediction);
if(message.server != null) messageDict.put("server",message.server);
if(message.serverMode != null) messageDict.put("serverMode",message.serverMode);
if(message.chat_type != null) messageDict.put("chat_type",message.chat_type);
_cb.onVerifedChat(messageDict);
}
}
public void ChatMessage(String msg){
if(AltoClef.inGame())
_mod.getMessageSender().enqueueChat(msg, MessagePriority.ASAP);
//Object myPythonClass = _mod.getGateway().getPythonServerEntryPoint(new Class[]{MyPythonClass.class});
}
public void RunInnerCommand(String command){
AltoClef.getCommandExecutor().execute(command); //@stop
}
public void CaptchaSolvedSend(String msg, double accuracy){
if(AltoClef.inGame()) {
Debug.logMessage("GOT CAPTCHA SOLVING! >"+msg+"< acc="+accuracy);
_mod.getMessageSender().enqueueChat(msg, MessagePriority.ASAP);
}
//Object myPythonClass = _mod.getGateway().getPythonServerEntryPoint(new Class[]{MyPythonClass.class});
}
public void ExecuteCommand(String cmd){
_mod.getCommandExecutor().execute(cmd);
}
public Map<String,String> CentralGameInfoDict = new HashMap<>();
public void UpdateServerInfo(String field, String value){
if (!field.isBlank() && !value.isBlank()) {
if (CentralGameInfoDict.containsKey(field)) {
if (!CentralGameInfoDict.get(field).equals(value)) {
Debug.logMessage("changed srv INFO f>" + field + ", v>" + value);
putInfo(field, value);
}
} else {
Debug.logMessage("added srv INFO f>" + field + ", v>" + value);//, dict="+CentralGameInfoDict.toString());
putInfo(field, value);
}
}
}
void putInfo(String field, String value){
CentralGameInfoDict.put(field, value);
if(IsCallbackServerStarted()) {
_cb.onUpdateServerInfo(CentralGameInfoDict);
}
}
public void onChatMessage(String msg){
if(IsCallbackServerStarted()) {
_cb.onChatMessage(msg);
}
}
public void onDeath(String killer){
if(IsCallbackServerStarted()) {
_cb.onDeath(killer);
}
}
public void onKill(String killed){
if(IsCallbackServerStarted()) {
_cb.onKill(killed);
}
}
public void onCaptchaSolveRequest(byte[] image_bytes){
if(IsCallbackServerStarted()) {
Debug.logMessage("SENDING TO CALLBACK!");
_cb.onCaptchaSolveRequest(image_bytes);
}
}
public void onDamage(float amount){
if(IsCallbackServerStarted()) {
_cb.onDamage(amount);
}
}
public Vec3d Nuller(){
return null;
}
public Rotation getGoalRotation(){
Rotation result = null;
if (AltoClef.inGame()){
Vec3d goal = getCurrentGoal();
if(goal != null){
Rotation targetrot = LookHelper.getLookRotation(_mod,goal);
result = LookHelper.getLookRotation().subtract(targetrot);
}
}
return result;
}
public Vec3d getCurrentGoal(){
Vec3d result = null;
if (AltoClef.inGame()) {
Optional<IPath> pathq = _mod.getClientBaritone().getPathingBehavior().getPath();
BetterBlockPos goalpos = null;
if (pathq.isPresent()) {
List<BetterBlockPos> pathlist = pathq.get().positions();
if (pathlist.size() > 0) {
goalpos = pathlist.get(pathlist.size() - 1);
result = new Vec3d(goalpos.getX(), goalpos.getY(), goalpos.getZ());
//Debug.logMessage("goalpos x="+goalpos.getX()+" y="+goalpos.getY());
}
}
}
return result;
//_mod.getClientBaritone().getCustomGoalProcess().getGoal().toString();
//return _mod.getClientBaritone().getGetToBlockProcess().GetToBlockCalculationContext.;
//_mod.getTaskRunner().getCurrentTaskChain().getTasks().
}
public void callPythonMethod(){
//_mod.getGateway().getGateway().getCallbackClient().sendCommand("trysi"); //command, blocking?
}
public double getHealth(){
return _mod.getPlayer() == null ? 0 :(double)_mod.getPlayer().getHealth();
}
public double getSpeed(){
return _mod.getPlayer() == null ? 0 :(double)_mod.getPlayer().getMovementSpeed();
}
public Vec3d getSpeedVector(){
return _mod.getPlayer() == null ? new Vec3d(0,0,0) : _mod.getPlayer().getVelocity();
}
public double getPitch(){
return _mod.getPlayer() == null ? 0 : _mod.getPlayer().getPitch();
}
public double getPitch(double TickDelta){
return _mod.getPlayer() == null ? 0 :_mod.getPlayer().getPitch((float)TickDelta);
}
public double getYaw(){
return _mod.getPlayer() == null ? 0 :_mod.getPlayer().getYaw();
}
public double getYaw(double TickDelta){
return _mod.getPlayer() == null ? 0 :_mod.getPlayer().getYaw((float)TickDelta);
}
public Vec3d getAngVector(){
return _mod.getPlayer() == null ? new Vec3d(0,0,0) :_mod.getPlayer().getRotationVector();
}
public double getSpeedX(){
return _mod.getPlayer() == null ? 0 :_mod.getPlayer().getVelocity().getX();
}
public double getSpeedY(){
return _mod.getPlayer() == null ? 0 :_mod.getPlayer().getVelocity().getY();
}
public double getSpeedZ(){
return _mod.getPlayer() == null ? 0 :_mod.getPlayer().getVelocity().getZ();
}
public double getSpeedXZ(){
return _mod.getPlayer() == null ? 0 :Math.sqrt(Math.pow(_mod.getPlayer().getVelocity().getX(),2)+Math.pow(_mod.getPlayer().getVelocity().getZ(),2));
}
}
Запустим каллбек и точку входа из класса инициализации мода (я вставил только строчки кода с инициализацией).
Кодопомойка фрагмента AltoClef.java
package adris.altoclef;
public class AltoClef implements ModInitializer {
private static GatewayServer _gatewayServer;
private static Py4jEntryPoint _py4jEntryPoint;
_py4jEntryPoint = new Py4jEntryPoint(this);
_gatewayServer = new GatewayServer(_py4jEntryPoint);
_gatewayServer.start();
//ClientServer clientServer = new ClientServer(null, 25333);
//_gatewayServer.getGateway().getCallbackClient().
if (_gatewayServer != null ) {
System.out.println("Gateway Server started on port "+_gatewayServer.getPort()+". Listeting port: "+_gatewayServer.getListeningPort());
}
_py4jEntryPoint.InitPythonCallback();
}
Также дополним обработчик сообщений в классе adris.altoclef.butler
методами и чеками для того, чтобы выстрелить ивенты о написании сообщений в Python‑части.
Упс, а здесь кода не будет, эту часть ты и сам сможешь повторить, дорогой читатель! Просто тут у меня концентрация грязекода зашкалила, а переписывать спустя год оказалось лень... Прошу понять, простить...
Кроме того нужно научить бота правильно парсить сообщения из чата майнкрафта. По-хорошему, это – отдельная тема на целую статью, но я запихаю всё в маленький спойлер :)
АД (или парсинг чата современных серверов Minecraft)
На самом деле мод AltoClef уже содержал в себе парсер чата Minecraft, но он работал изначально только с ванильной версией:
Ванильная версия чата
<ник1> привет
<ник2> пока
Не беда, подумал я, там ведь был универсальный шаблон, однако не тут то было... Оригинальный парсер мог работать только с фиксированными шаблонами сообщений и не имел никакой защиты от REGEX-чувствительных символов в сообщениях. Что ж, придётся мне это пилить самому. Чтобы вы понимали масштаб проблемы, сейчас я приведу несколько примеров сообщений из чата типичного русского сервера Minecraft:
Примеры сообщений из типичных серверов Minecraft
Обратите внимание, что сообщения очень разные даже в пределах одного сервера. Передаются они путём простого засылания пакета с полным текстом сообщения в чате, так что спарсить что-то без перебора не получится. Сложность парсинга, прежде всего, в том, что какие-то элементы могут быть, а могут – и не быть.
Например, если на сервере установлены кланы, то игрок, находящийся в клане, будет иметь тег клана, а игрок без клана – не будет его иметь. Кланы, кстати, не всегда выделяются квадратными скобками, иногда просто пробелами.
Кроме того, самый сущий ад – это наличие префиксов и суффиксов, которые могут быть по отдельности, или не быть вообще, или быть все вместе! Я уже не говорю о том, что те же суффиксы могут быть с пробелами, что просто уничтожит механизм парсера, но, к счастью, такое – редкость.
Так... Вы уже отошли от шока? Я до сих пор нет. Думаю, любой уважающий себя человек, представляющий, как работают парсеры, понимает, что парсить это – сущий кошмар. Только у меня для вас одна неприятная новость. Кто, если не мы?..
В общем, я решился. И я это дело сделал, правда, местами коряво, если включать автодетектор чата сервера, но для прототипа уж точно сойдёт.
Говоря о реализации, для начала я решил собрать все виды их этих вонючих стрелок:
"➥","->","➡","➥","➯","➨","›","►","⋙","»","⪼","⇨"
Потом собираем всевозможные теги, разные по смыслу в каждых серверах:
"{team}","{global}","{starterPrefix}","{donate}","{suffix}","{clan}","{rank}", "{from}", "{to}", "{message}"
Любые другие теги нам учитывать необязательно, но, если на сервере они есть, в шаблон просто будем это вбивать как {любое_название}
, просто оно не будет парситься в данные, но будет учитываться во время распознавания.
Теперь доработаем скрипт парсера с учетом новых вводных. У меня везде включен автодетект, но, если вы захотите встроить это куда-то себе, рекомендую отключить его, передав в переборщик шаблоны только конкретного, нужного сервера.
Кодопомойка ChatChecker.java
package adris.altoclef.butler;
import adris.altoclef.AltoClef;
import adris.altoclef.Debug;
import adris.altoclef.util.time.TimerGame;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class WhisperChecker {
private static final TimerGame _repeatTimer = new TimerGame(0.1);
private static String _lastMessage = null;
public static MessageResult tryParse(String ourUsername, String whisperFormat, String message) {
List<String> parts = new ArrayList<>(Arrays.asList("{from}", "{to}", "{message}"));
// Sort by the order of appearance in whisperFormat.
parts.sort(Comparator.comparingInt(whisperFormat::indexOf));
parts.removeIf(part -> !whisperFormat.contains(part));
String regexFormat = Pattern.quote(whisperFormat);
for (String part : parts) {
regexFormat = regexFormat.replace(part, "(.+)");
}
if (regexFormat.startsWith("\\Q")) regexFormat = regexFormat.substring("\\Q".length());
if (regexFormat.endsWith("\\E")) regexFormat = regexFormat.substring(0, regexFormat.length() - "\\E".length());
//Debug.logInternal("FORMAT: " + regexFormat + " tested on " + message);
Pattern p = Pattern.compile(regexFormat);
Matcher m = p.matcher(message);
Map<String, String> values = new HashMap<>();
if (m.matches()) {
for (int i = 0; i < m.groupCount(); ++i) {
// parts is sorted, so the order should lign up.
if (i >= parts.size()) {
Debug.logError("Invalid whisper format parsing: " + whisperFormat + " for message: " + message);
break;
}
//Debug.logInternal(" GOT: " + parts.get(i) + " -> " + m.group(i + 1));
values.put(parts.get(i), m.group(i + 1));
}
}
if (values.containsKey("{to}")) {
// Make sure the "to" target is us.
String toUser = values.get("{to}");
if (!toUser.equals(ourUsername)) {
Debug.logInternal("Rejected message since it is sent to " + toUser + " and not " + ourUsername);
return null;
}
}
if (values.containsKey("{from}") && values.containsKey("{message}")) {
MessageResult result = new MessageResult();
result.from = values.get("{from}");
result.message = values.get("{message}");
return result;
}
return null;
}
public static MessageResult chatParse(String ourUsername, String[] chatFormatMas, String message) {
return chatParse(ourUsername, chatFormatMas, message, "exact");
}
public static MessageResult chatParse(String ourUsername, String[] chatFormatMas, String message, String ExactState) {
List<String> parts = new ArrayList<>(Arrays.asList("{team}","{global}","{starterPrefix}","{donate}","{suffix}","{clan}","{rank}", "{from}", "{to}", "{message}"));
String serverName = chatFormatMas[0];
String serverMode = chatFormatMas[2];
String chatFormatNew = new String(chatFormatMas[1]);
// Sort by the order of appearance in whisperFormat.
message = message.replace("\\","");
//заменяем стрелки
List<String> arrows = new ArrayList<>(Arrays.asList("➥","->","➡","➥","➯","➨","›","►","⋙","»","⪼","⇨")); //https://ru.piliapp.com/symbol/arrow/
for (String arrow : arrows){
if (!chatFormatNew.contains(arrow)) { //ЕСЛИ ШАБЛОН НЕ СОДЕРЖИТ ОДНУ ИЗ ЭТИХ СТРЕЛОК ТОГДА РЕПЛАЙСАЕМ ЕСЛИ НЕТ ТО ИДЕМ ПО ШАБЛОНУ ТАК БУДЕТ ТОЧНЕЕ!
message = message.replace(arrow, ">");
}
}
List<Character> regexKillingChars = new ArrayList<>(Arrays.asList('[',']','.','^','?','*','$','(',')','/','|','+'));
//Debug.//logMessage("Do:"+message);
for (Character killer : regexKillingChars){
String charr = killer.toString();
chatFormatNew = chatFormatNew.replace(charr,"\\"+charr);
}
String chatFormat = chatFormatNew;
parts.sort(Comparator.comparingInt(chatFormat::indexOf));
parts.removeIf(part -> !chatFormat.contains(part));
//Debug.logMessage("Posle:"+message);
////Я НА ЭТОМ Е**** ВЕСЬ ДЕНЬ ****
String regexFormat = Pattern.quote(chatFormat);
for (String part : parts) {
//Debug.logMessage("4o"+part);
regexFormat = regexFormat.replace(part, "(.+)");
}
if (regexFormat.startsWith("\\Q")) regexFormat = regexFormat.substring("\\Q".length());
if (regexFormat.endsWith("\\E")) regexFormat = regexFormat.substring(0, regexFormat.length() - "\\E".length());
//Debug.logInternal("FORMAT: " + regexFormat + " tested on " + message);
Pattern p = Pattern.compile(regexFormat);
Matcher m = p.matcher(message);
Map<String, String> values = new HashMap<>();
if (m.matches()) {
//Debug.logMessage("4o 3a dermo"+m.toString());
for (int i = 0; i < m.groupCount(); ++i) {
// parts is sorted, so the order should lign up.
if (i >= parts.size()) {
Debug.logError("Invalid whisper format parsing: " + chatFormat + " for message: " + message);
break;
}
//Debug.logInternal(" GOT: " + parts.get(i) + " -> " + m.group(i + 1));
values.put(parts.get(i), m.group(i + 1));
}
}
if (values.containsKey("{to}")) {
// Make sure the "to" target is us.
String toUser = values.get("{to}");
if (!toUser.equals(ourUsername)) {
Debug.logInternal("Rejected message since it is sent to " + toUser + " and not " + ourUsername);
return null;
}
}
List<Character> nickKillingChars = new ArrayList<>(Arrays.asList('~','[',']','.','^','?','*','$','(',')','/','|','+'));
if (values.containsKey("{from}") && values.containsKey("{message}")) {
String name = values.get("{from}");
if(name != null) {
if (name != null && name.strip() != "") {
String[] splittedName = name.strip().split(" ");
if (splittedName.length>0) {
if(splittedName.length==1){
name = splittedName[0];
} else if (splittedName.length==2) {//[A-Za-z0-9]
name = splittedName[0]; //[бог] _nyaka Красавица :
} else if (splittedName.length==3) {
name = splittedName[0]; //[президент] Гений lexa Богач :
} else{
name = splittedName[0];
}
for (Character killer : nickKillingChars){
String charr = killer.toString();
name = name.replace(charr,"");
}
//Debug.logMessage("4o nado"+message);
MessageResult result = new MessageResult();
if (values.containsKey("{starterPrefix}"))
result.starter_prefix = values.get("{starterPrefix}");
if (values.containsKey("{rank}")) result.rank = values.get("{rank}");
if (values.containsKey("{clan}")) result.clan = values.get("{clan}");
if (values.containsKey("{team}")) result.team = values.get("{team}");
if (values.containsKey("{global}")) result.chat_type = values.get("{global}");
//if (values.containsKey("{rank}")) result.rank = values.get("{rank}");
result.server = serverName;
result.serverMode = serverMode;
result.serverExactPrediction = ExactState;
result.from = name;
result.message = values.get("{message}");
return result;
}
}
}
}
return null;
}
public MessageResult receiveChat(AltoClef mod, String ourUsername, String msg, String server, String servermode) {
String foundMiddlePart = "";
int index = -1;
boolean duplicate = (msg.equals(_lastMessage));
if (duplicate && !_repeatTimer.elapsed()) {
_repeatTimer.reset();
// It's probably an actual duplicate. IDK why we get those but yeah.
return null;
}
_lastMessage = msg;
//сначала проверяем находимся ли мы на этом сервере и в этом режиме
for (String[] format : ButlerConfig.getInstance().chatFormats) {
if (server.equals(format[0]) && servermode.equals(format[2])){
//Debug.logMessage("совпадение всё"+format[0]+format[1]+format[2]);
MessageResult check = chatParse(ourUsername, format, msg);
if (check != null) {
String user = check.from;
String message = check.message;
if (user == null || message == null) break;
return check;
}
}
}
//теперь проверим только сервер и будем для него перебирать варианты чтобы успешно найти ник и сообщение по шаблону
for (String[] format : ButlerConfig.getInstance().chatFormats) {
if (server.equals(format[0])){
//Debug.logMessage("совпадение серв"+format[0]+format[1]+format[2]);
MessageResult check = chatParse(ourUsername, format, msg,"server");
if (check != null) {
String user = check.from;
String message = check.message;
if (user == null || message == null) break;
return check;
}
}
}
//проверим универсальный тип
for (String[] format : ButlerConfig.getInstance().chatFormats) {
if ("universal".equals(format[0])){
//Debug.logMessage("совпадение юниверс"+format[0]+format[1]+format[2]);
MessageResult check = chatParse(ourUsername, format, msg, "universal");
if (check != null) {
String user = check.from;
String message = check.message;
if (user == null || message == null) break;
return check;
}
}
}
for (String[] format : ButlerConfig.getInstance().chatFormats) {
MessageResult check = chatParse(ourUsername, format, msg, "random");
if (check != null) {
String user = check.from;
String message = check.message;
if (user == null || message == null) break;
return check;
}
}
return null;
}
public MessageResult receiveMessage(AltoClef mod, String ourUsername, String msg) {
String foundMiddlePart = "";
int index = -1;
boolean duplicate = (msg.equals(_lastMessage));
if (duplicate && !_repeatTimer.elapsed()) {
_repeatTimer.reset();
// It's probably an actual duplicate. IDK why we get those but yeah.
return null;
}
_lastMessage = msg;
for (String format : ButlerConfig.getInstance().whisperFormats) {
MessageResult check = tryParse(ourUsername, format, msg);
if (check != null) {
String user = check.from;
String message = check.message;
if (user == null || message == null) break;
return check;
}
}
return null;
}
public static class MessageResult {
public String from;
public String message;
public String rank;
public String serverMode;
public String server;
public String clan;
public String team;
public String chat_type;
public String serverExactPrediction;
public String starter_prefix;
@Override
public String toString() {
return "MessageResult{" +
"from='" + from + '\'' +
", message='" + message + '\'' +
'}';
}
//public String getDetails(){
// //return "MessageDetails{" +
// "from='" + from + '\'' +
// ", message='" + message + '\'' +
// ", rank='" + server + '\'' +
// '}'
//
// ;
//}
}
}
Ууу, знали бы вы, сколько я потратил на это времени, сил и нервов. К слову, где-то я говорил, что оставлял в коде некоторые моменты, хмм, про что же это я...
Итак, механизм парсера у нас есть, накатаем парочку шаблонов для разных серверов. Учтём, что эти сервера могут иметь разные режимы и кучу шаблонов парсинга даже для одного из них.
Кодопомойка ButlerConfig.java/chatFormats
public String[][] chatFormats = new String[][]{
//Команда не найдена.
{"universal","<{from}> {message}","survival"},
//"? ? [пвапва] | [аыва] Khushin фвыыв ? 14234".
{"mc.musteryworld.net","{starterPrefix} [{clan}] | [{rank}] {from} > {message}","survival"},
{"mc.musteryworld.net","{starterPrefix} | [{rank}] {from} > {message}","survival"},
{"mc.musteryworld.net","[⚑] {from}: {message}","bedwars"},
{"mc.musteryworld.net","[{rank}] <{from}> {message}","skywars"},
{"mc.musteryworld.net","{from} ⋙ {message}","murdermystery"},
// murder
// BEDWARS
//[⚑] NetTyan: аа
//[Всем] NetTyan: е
{"mc.musteryworld.net","[Всем] {from}: {message}","bedwars"},
//162onmyhead ⋙ ник е*****
{"mc.musteryworld.net","SPEC: {from} > {message}","murdermystery"},
{"mc.musteryworld.net","{from} > {message}","murdermystery"},
// VIME MC MESSAGES TYPES
//ღ [G] §8[§f§f§lппп_IVANBANAN§8] | ᖧШУТᖨ ~koshmarik9090 Утопленник › Блин блинский
//(i) bxmew наложил мут на игрока apipka228 по причине: попрошайничество [ПОДРОБНЕЕ]
//ღ [G] | ᖧИмператорᖨ _twistyyyy › Какой аыва
//ღ [G] | ᖧStaffᖨ ~explyko › :33
//ღ [G] | ᖧunxyᖨ bexzsm1slzn ✔ 私と緒にいて › Тишее
//ღ [G] §8[§f§f§lппп_IVANBANAN§8] | ᖧШУТᖨ ~koshmarik9090 Утопленник › Ъхапъхапхъа пвапвап снятый
//ღ [G] §8[§f§f§oNyak§e§oy§8] | ᖧYouTubeᖨ HDemonH › ХАХАХ папап ору дима
//ღ [G] | ᖧModerᖨ bxmew ✔ 私と緒にいて › Довели
//ღ [L] | ᖧДелюксᖨ Oliver_1445 › Сказал же помоги мне с деньгами
//ღ [G] | ᖧИгрокᖨ wqhtxly Samurai ›
{"mc.vimemc.net","{starterPrefix} [{global}] [{clan}] | ᖧ{rank}ᖨ {from} > {message}","survival"},
{"mc.vimemc.net","{starterPrefix} [{global}] | ᖧ{rank}ᖨ {from} > {message}","survival"},
{"mc.vimemc.net","{starterPrefix} [{global}] [{clan}] | ᖧ{rank}ᖨ {from} {suffix} > {message}","survival"},
{"mc.vimemc.net","{starterPrefix} [{global}] | ᖧ{rank}ᖨ {from} {suffix} > {message}","survival"},
// THE PIT
//ingame //[18 уб.] [КОМАНДЕ/всем] NetTyan ► ээм
{"mc.vimemc.net","[{rank}] [{team}] {from} > {message}","skywars"},
{"mc.vimemc.net","[{rank}] ᖧ{donate}ᖨ {from} {suffix} > {message}","thepit"},
{"mc.vimemc.net","[{rank}] ᖧ{donate}ᖨ {from} > {message}","thepit"},
{"mc.vimemc.net","[{rank}] {from} > {message}","thepit"},
// SKYWARS
{"mc.vimemc.net","[{rank}] [{team}] {from} ⇨ {message}","skywars"},
{"mc.vimemc.net","[{rank}] ᖧ{donate}ᖨ {from} {suffix} > {message}","thepit"},
{"mc.vimemc.net","[{rank}] ᖧ{donate}ᖨ {from} > {message}","thepit"},
{"mc.vimemc.net","[{rank}] {from} > {message}","thepit"},
//MURDER MYSTERY nick ⇨ msg
{"mc.vimemc.net","ᖧ{donate}ᖨ {from} {suffix} ⇨ {message}","murdermystery"},
{"mc.vimemc.net","ᖧ{donate}ᖨ {from} ⇨ {message}","murdermystery"},
{"mc.vimemc.net","{from} ⇨ {message}","murdermystery"},
//gamestarting //[18 уб.] NetTyan ► ээм
{"mc.vimemc.net","[{rank}] {from} > {message}","skywars"},
//lobby //nick > msg
{"mc.vimemc.net","{from} > {message}","skywars"},
//funny mc
{"funnymc.ru","{starterPrefix} {global} ({clan}) [{rank}] {from} ➯ {message}","survival"},
{"funnymc.ru","{starterPrefix} {global} ({clan}) {rank} {from} ➯ {message}","survival"},
{"funnymc.ru","{starterPrefix} {global} [{rank}] {from} ➯ {message}","survival"},
{"funnymc.ru","{starterPrefix} {global} {rank} {from} ➯ {message}","survival"},
{"funnymc.ru","{global} ({clan}) [{rank}] {from} ➯ {message}","survival"},
{"funnymc.ru","{global} ({clan}) {rank} {from} ➯ {message}","survival"},
{"funnymc.ru","{global} [{rank}] {from} ➯ {message}","survival"},
{"funnymc.ru","{global} {rank} {from} ➯ {message}","survival"},
{"funnymc.ru","[{rank}] {from} » {message}","skywars"},
{"funnymc.ru","({rank}) {from} > {message}","skywars"},
{"funnymc.ru","{from} » {message}","mudermystery"},
{"mc.4obabke.ru","{from} whispers to you: {message}","skywars"}
};
К слову, с таким парсером и включенным автодетектом мы даже можем определять сервер и режим, на котором играем, если парсеру удастся подобрать шаблон! Правда, как я уже говорил, работает оно пока так себе.
Также, дополнительно я изучил технологию Mixin в Fabric и реализовал пару функций, таких как, например, отслеживание последнего ударившего игрока, чтобы можно было позволить дать возможность системе отреагировать на свою смерть в игре. Но моя реализация была «на скорую руку» и работала крайне коряво, поэтому этого кода здесь тоже не будет. По большей части проблемы здесь были связаны с тем, что мы имеем дело с клиентским модом, а взаимодействие с игроками и отслеживание информации о них — дело сервера, поэтому встроенные возможности среды для модов Fabric в данном случае были очень ограничены, что заставило меня как‑то «выкручиваться». Я обращался даже к спецам разработки модов на Fabric, они посоветовали анализировать сетевые пакеты с сервера напрямую, что предвещало очень трудную работу, поэтому я отложил это дело и оставил как есть.
Кроме того, простой бот на алгоритмах — это ведь временное решение. В будущем мы ведь хотим, чтобы агенn играл в майн как человек, только с помощью визуальных и звуковых данных, ведь так намного круче! (те системы по типу MineRL, что есть на данный момент, кажутся мне слабоватыми для полноценных стримерских задач, для более крутой реализации я бы дождался выхода более навороченных фреймворков или сделал бы его сам, когда‑нибудь)
И что же мы получили? Воу‑воу‑воу! Это же самая настоящая киборг‑машина‑убийца!
Гифки с демонстрацией работы киборга-убийцы маленьких майнкрафтеров
Здесь я вставил несколько фрагментов со стримов с демонстрацией некоторых игровых функций бота.
Режим SkyWars в Minecraft начинается с выпуска каждого игрока в колбу над своим островом. На островах есть сундуки с ценными ресурсами, которые игроки должны собирать, чтобы получать различные преимущества. Таким образом, при получении сообщения о начале игры бот активирует написанный ранее таск для режима SkyWars, в одну из задач которого входит добыча ресурсов из сундуков.
При встрече с игроками кроме получения необходимых ресурсов (брони, мечей и т. п.), чтобы хоть как‑то сравниться с живыми игроками, бот должен уметь пользоваться такими плюшками, как золотые яблоки и эндер‑жемчуги.
Наконец, сочетание разнообразных навыков и скорость реакции бота на алгоритмах делают своё дело — бот способен побеждать в бою реальных людей и собирать с них ресурсы!
Если нет возможности приблизиться к игроку, нужно уметь использовать и дальнее оружие. В Minecraft это чаще всего лук. В качестве бонуса я научил бота стрелять не только по кратчайшей параболе, но и навесом. Такая артиллерия точно преподнесёт игрокам, спрятавшимся где‑нибудь за горой, нежданчик, не говоря уже о том, что за разнообразными тактиками боя зрителям будет интересно наблюдать!
Итак, теперь, имея рабочий прототип бота, который может полностью сам играть и даже выигрывать (около 5 случаев побед на 100 игр [истерический смех]) в режиме SkyWars Minecraft хотя бы на одном из немодерируемых серверов, мы можем переходить к виртуальному аватару и связующему звену между Minecraft и Python‑скриптом.
Важно упомянуть, что для захода на сервер Minecraft, тем более немодерируемый, из‑за большого количества ботоводов на нём (вредоносных, а не развлекательных, как у нас) необходимо было вводить капчу. К сожалению, на момент написания статьи мое решение уже неактуально т. к. там сменили обычную капчу в виде карты на очень тяжелую анимированную, тем не менее это может быть интересно в качестве опыта, как я решал этот кейс на тот момент имея по сути 0 знаний в картинковом DL и Tensorflow. В спойлере ниже я раскрою детали того, как дообучал Tensorflow OCR модель распознавать майнкрафт‑капчу. А ещё я зачем-то аплоаднул это дело на хг спейс и github, так что можете сами потыкать.
MinecraftMapCaptchaSolver.useless
Итак, чтобы собрать механизм решения капчи, нам нужно:
Собрать датасет из минимум 1000 капч
Обучить/дообучить модель распознавания текста/картинок на них
Внедрить в игру (развёртывание)
Мутим сборщик датасета (Java)
Для начала нам нужно понять, как получить доступ к самим картам из игры.
Получаем карту в удобном виде (Java)
Для начала нам нужно накодить такую штукенцию как Mixin Accessor (ранее упоминалось при коде Java‑части), чтобы получить доступ к объекту карты из игрового мира Minecraft.
Кодопомойка для получения доступа к картам (Java)
MapTextureAccessor.java
package adris.altoclef.mixins;
import net.minecraft.client.render.MapRenderer;
import net.minecraft.client.texture.NativeImageBackedTexture;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(MapRenderer.MapTexture.class)
public interface MapTextureAccessor {
@Accessor("texture")
NativeImageBackedTexture getNativeImage();
}
MapRendererInvoker.java
package adris.altoclef.mixins;
import net.minecraft.client.render.MapRenderer;
import net.minecraft.client.texture.TextureManager;
import net.minecraft.item.map.MapState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
import org.spongepowered.asm.mixin.gen.Invoker;
@Mixin(MapRenderer.class)
public interface MapRendererInvoker {
@Invoker("getMapTexture")
MapRenderer.MapTexture invokeGetMapTexture(int id, MapState state);
}
Созданные классы вносим в resources, assets altoclef.mixins.json в client-часть, чтобы компилятор не забыл про них при сборке.
Далее накодим себе помощника с кучей инструментов, которые нам могут потребоваться при работе с картами.
Кодопомойка MapItemHelper.java
package adris.altoclef.util.helpers;
import adris.altoclef.AltoClef;
import adris.altoclef.Debug;
import adris.altoclef.util.ImageComparer;
import net.minecraft.client.MinecraftClient;
import net.minecraft.item.FilledMapItem;
import net.minecraft.item.ItemStack;
import net.minecraft.item.Items;
import net.minecraft.item.map.MapState;
import net.minecraft.client.render.MapRenderer;
import adris.altoclef.mixins.MapRendererInvoker;
import adris.altoclef.mixins.MapTextureAccessor;
import adris.altoclef.mixins.ScreenshotRecorderInvoker;
import net.minecraft.client.texture.NativeImage;
import net.minecraft.util.Util;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
public class MapItemHelper {
public static String saveNonExistMapToDataset(AltoClef mod){
return saveNonExistMapToDataset(mod,false);
}
public static String saveNonExistMapToDataset(AltoClef mod, boolean neural_solve){
ItemStack item = ItemHelper.getHandItem(mod);
if (item != null){
return saveNonExistMapToDataset(item, mod, neural_solve);
}
return "";
}
public static String saveNonExistMapToDataset(ItemStack stack, AltoClef mod) {
return saveNonExistMapToDataset(stack, mod, false);
}
public static String saveNonExistMapToDataset(ItemStack stack, AltoClef mod, boolean neural_solve) {
//Debug.logMessage("itemstack"+stack.getName()+stack.isOf(Items.FILLED_MAP));
if(stack.isOf(Items.FILLED_MAP)){
Integer mapId = FilledMapItem.getMapId(stack);
MapState mapState = FilledMapItem.getMapState(mapId, mod.getWorld());
if (mapState != null){
String saveResult = saveMapFile(mod, mapId,mapState, neural_solve);
//Debug.logMessage(""+saveResult);
return saveResult;
}
}
return "";
}
public static String saveMapFile(AltoClef mod, Integer mapId, MapState mapState, boolean neural_solve){
File screensDir = new File(MinecraftClient.getInstance().runDirectory,"map_screenshots");
screensDir.mkdir();
MapRenderer.MapTexture map_texture = ((MapRendererInvoker)MinecraftClient.getInstance().gameRenderer.getMapRenderer()).invokeGetMapTexture(mapId, mapState);
//Debug.logMessage("map texture"+map_texture);
File screenshot = ScreenshotRecorderInvoker.invokeGetScreenshotFileName(screensDir);
//Debug.logMessage("screenshotFile"+screenshot.getAbsolutePath()+" choo "+screenshot.getName());
NativeImage img = ((MapTextureAccessor)map_texture).getNativeImage().getImage();
String check_result = "";
try {
byte[] bytes_img = img.getBytes();
check_result = ImageComparer.checkBytesImageInDataset(bytes_img);
if (check_result == "") {
if(neural_solve){
mod.getInfoSender().onCaptchaSolveRequest(bytes_img);
}
else {
saveImageFile(bytes_img, screenshot);
}
}else{
if(check_result.equals("black.png")){
Debug.logMessage("[CAPTCHA NOT LOADED] BLACK FILE!!! = '" + check_result + "'");
check_result = "";
} else if(check_result.length()>9){ //"44235.png"
Debug.logMessage("[CAPTCHA NOT TOO LONG FILENAME!!! = "+check_result);
check_result = "";
}
else {
Debug.logMessage("[CAPTCHA FOUND] FILE EXISTS = '" + check_result + "'");
}
return check_result;
}
}catch (Exception e){
e.printStackTrace();
Debug.logMessage("ERR WHEN CHECHING CAPTCHA!!!!!"+e.toString());
}
return "";
}
public static void saveImageFile(byte[] bytes_img, File screenshot){
Util.getIoWorkerExecutor().execute(() -> {
try {
BufferedImage buffered_img = ImageComparer.byte2BufferedImage(bytes_img);
ImageIO.write(buffered_img,"png",screenshot);
Debug.logMessage("[CAPTCHA] IMAGE SAVED! Name="+screenshot.getName());
//((MapTextureAccessor) map_texture).getNativeImage().getImage().writeTo(screenshot);
//Text text = (new LiteralText(screenshot.getName())).formatted(Formatting.UNDERLINE, Formatting.GREEN).styled((style) -> {
// return style.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_FILE, screenshot.getAbsolutePath()));
//});
//Debug.logMessage("IMAGE SAVED!");
//MinecraftClient.getInstance().player.sendMessage(new TranslatableText("map_saver.success", "Map #" + mapId, text), false);
//
} catch (IOException e) {
e.printStackTrace();
}
//
});
}
}
Итак, когда у нас есть механизм удобного «чтения» и записи карт, займёмся сбором датасета. В том же Java накодим простенький механизм его сбора. Попутно внедрим туда функционал по распознаванию капчи (пока что используя «холостые» методы, которые мы реализуем позже).
Кодопомойка Butler.java/captchaActionsPerform
private void captchaActionsPerform(){
if (_captchaTimer.elapsed()) {
_captchaTimer.reset();
Perspective old_perspective = null;
if(CaptchaSolvingMode.equals("GET_DATASET")) { //"SOLVE_MAXIMUM"; //GET_DATASET SOLVE_DATASET_ONLY
stuck_fix_butler_allow = false;
//_mod.getCommandExecutor().execute("@test killall");
Debug.logMessage("КАПЧА СБОР ДАТАСЕТА!");
old_perspective = MinecraftClient.getInstance().options.getPerspective();
MinecraftClient.getInstance().options.setPerspective(Perspective.FIRST_PERSON);
MapItemHelper.saveNonExistMapToDataset(_mod);
//_mod.getCommandExecutor().execute("@idle");
this.reJoin(3000, _mod);
//DeathMenuChain.disconnect(MinecraftClient.getInstance());
}else if(CaptchaSolvingMode.contains("SOLVE")){
Debug.logMessage("КАПЧА РЕШЕНИЕ РЕЖИМ = "+CaptchaSolvingMode);
old_perspective = MinecraftClient.getInstance().options.getPerspective();
MinecraftClient.getInstance().options.setPerspective(Perspective.FIRST_PERSON);
boolean neural_captcha_solve = false;
if(CaptchaSolvingMode.equals("SOLVE_MAXIMUM") && _mod.getInfoSender().IsCallbackServerStarted()) {
neural_captcha_solve = true;
}
String captchaImageFilename = MapItemHelper.saveNonExistMapToDataset(_mod, neural_captcha_solve);
String captcha_solving = "";
if (captchaImageFilename.isBlank()){ // Checks if a String is whitespace, empty ("") or null.
Debug.logMessage("КАПЧА НЕ НАЙДЕНА В ДАТАСЕТЕ!");
if(neural_captcha_solve){
Debug.logMessage("Отправляем в инфо сендер!!");
//INFO SENDER
//captcha_solving = AltoClef.getInfoSender().getCaptchaSolving...
return;
}else {
Debug.logMessage("ВЫДАЕМ РАНДОМНЫЙ НОМЕР!");
//если ничего не передали в решение и не решено то фигач рандом от 1000 до 99999
captcha_solving = Integer.toString((ThreadLocalRandom.current().nextInt(1000, 99999 + 1))); //(min, max + 1);
}
}else{
//captcha_solving = captchaImageFilename.split("\\.")[0];
captcha_solving = captchaImageFilename.split(Pattern.quote("."))[0].split(Pattern.quote("_"))[0];
}
if (!captcha_solving.isEmpty()) { //Checks if a String is empty ("") or null.
Debug.logMessage("ВВОД КАПЧИ / ENTERING SOLVED CAPTCHA ="+captcha_solving);
_mod.getMessageSender().enqueueChat(captcha_solving, MessagePriority.TIMELY);
}
if (old_perspective != null){
MinecraftClient.getInstance().options.setPerspective(old_perspective);
}
}
}else{
Debug.logMessage("КАПЧА УЖЕ РЕШАЕТСЯ!");
}
}
Далее швыряем наш механизм в обработчик сообщений (прям так, мы же самые наглые)!
if (msg.contains("Введите капчу с картинки в чат")) {
this.captchaActionsPerform();
}
Добавим пару мелочей в обработчик команд, чтобы можно было включить и отключить сборщик капчи (ну тут кода не будет, если я и на такие вещи буду вставлять код, у нас с вами статья выйдет размером со сценарий Санта‑Барбары).
Так, теперь нам осталось перезайти на сервер раз так пару тысяч...
Собираем датасет
Включаем сборщик капчи и оставляем бота перезаходить.
Каждые пару секунд в папке с капчами появляется новая, переименовываем её в соответствии со значением. Повторяем процесс пару тысяч раз.
Если пикча у капчи другая, но значение такое же — делаем приписку «_N» (где N — номер повторения) к файлу.
Всего было собрано 1390 картинок, на процесс ушло около недели и за это время получено несколько банов на различных серверах. Итак, датасет есть, попробуем обучить модельку!
Про обучение
Для качественного обучения и проверки его качества разделим картинки следующим образом:
100 картинок (должно быть 10% от общих 1389, но мы же любим красивые числа) отделяем от общего датасета и оставляем для непредвзятой проверки аккуратности модели.
Остальные 1290 нещадно отдаём на корм обучающей машине...
Далее надо решить, что мы будем обучать. На глаза мне тогда очень кстати попалась эта статья. Там автор очень интересным образом распознавал текстовую капчу на примере VK с помощью Tensorflow Keras OCR.
Соответственно, за основу я брал код автора, а в своих скриптах просто адаптировал его настройки под новый размер капчи — 128×128, но по итогу всё равно конвертировал квадратные картинки в горизонтальные. Изменить пришлось и список символов:
characters = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
Изначально я готовил модель под работу с квадратными капчами, но такой подход не позволил мне добиться точности выше 50%, что, вероятно, связано, с конфигурацией самой создаваемой ИИ модели, а сильно заморачиваться, разбираться в её форме я не хотел (будто бы предвидел, что занимаюсь бесполезной фигней, которая в скором времени станет неактуальной, как это и случилось).
Таким образом, так как на 1300 картинок и 100 эпох уходило пару десяток минут, я решил действовать экспериментальным путём — просто менять разные настройки и смотреть, что понизит лосс и повысит аккуратность итоговой модели.
Вот некоторые наблюдения за параметрами по результатам экспериментов:
Оптимальный входной размер картинки оказался 70×50, больше или меньше — начинает швыряться лосс.
Оптимальное число эпох (epochs) составило 100. Больше можно, но иногда модель перетренировывалась, меньше — недотренировывалась, и то и то ухудшало аккуратность на тестовом датасете.
-
Оптимальное число батчей (batch_size) — 16
Как это ни странно, уменьшение количества картинок при обучении тоже влияло, причем не всегда в плохую сторону, и за погрешность тоже не посчитать — иногда влияло на 10–20%.
Кладбище (помойка) экспериментальных моделей
Модели я сохранял в папки и подписывал их как попадалось. Вот некоторые из них (в среднем до 1000 картинок, неудачные):
Вот модели получше, когда я увеличил трейн-датасет и уже начал что-то понимать:
.
Итак, мы добились точности в 83%, чего, бесспорно, мало, но учитывая тот факт, что сервер даёт несколько шансов на ввод капчи, и так сойдёт. Мы ведь не мегахацкеры‑ломацкеры, а этот механизм — скорее эксперимент для прототипа, поэтому смысла особо потеть тут нет, к тому же, меня всё ещё не покидало ощущение, что я делаю это зря, как потом и оказалось. С другой стороны, извлеченный опыт я бы мог назвать очень даже полезным!
Согласно той же статье, конвертируем модель в оннх, чтобы не подгружать весь Tensorflow для каждого решения капчи. Использовать этого капчерешалу мы будем далее из Python‑части процесса связи между Minecraft и главным Python‑скриптом посредством очередей. При сообщении «введите капчу» будем отправлять запрос с байтовым представлением пнг капчи из игры в Python‑часть. Там этот запрос будет добавляться в очередь на распознавание, и по завершении вызывать метод ввода в чат цифорок из капчи! Сам код процесса связи будет описан чуть ниже в статье, при подключении виртуального аватара.
Just Python
Дальше – только питон...
Подключение виртуального аватара
Так как здесь стек технологий нам уже понятен, проблем быть не должно: скачиваем VTube Studio, ставим туда вышеупомянутую модельку Live2D, делаем простенькие настройки в самой программе — и вуаля, наша кошко‑девочка уже анимирована и даже виляет хвостом!
Гифка хвостатой
Фон поставил зелёный, чтобы потом в OBS по цветовому ключу вырезать и наложить поверх игры.
Я бы с радостью так и оставил, но, увы, в этот раз мою любимую лень придётся придержать, ведь одно из моих изначальных требований было сделать связь аватара с игрой.
Кодим-кукодим
Для ускорения реализации я воспользовался репозиторием VsPyYt, расковырял его и модифицировал нужные функции в файле vsnoyt так, чтобы их потом можно было использовать в программе. В VTube с помощью вебсокета можно как передавать кастомные значения переменных (и потом, например, двигать с их помощью глаза персонажа из Python‑части) и ивенты, например, на время дать анимацию грусти или радости.
Кодопомойка VTube/vsnoyt.py (Process func)
from setup import *
if os.path.exists('custom.py'):
import customfunc
from customfunc import *
def VtubeProcess(vtube_ctx, ctx):
import json
import time
import os
import setup
from setup import setup
import threading
async def wsconnect():
fail = True
while fail and ctx.ThreadsActived:
try:
ez = await websockets.connect('ws://127.0.0.1:8001')
fail = False
if (not ctx.IsVtubeStarted):
ctx.IsVtubeStarted = True
# print('CONNECTION SUCCESSFUL')
return ez
except BaseException as err:
ctx.IsVtubeStarted = False
# print('!! **VTUBE STUDIO CONNECTION FAILED** !!',err)
time.sleep(5)
# return False
async def DoEvent(websocket, event_name="CryButNot", event_type="hotkey", bool_val=True):
async def innerFunc():
if event_type == "hotkey":
await ExHotkey(websocket, event_name) # xHotkey(websocket,hid,IID):
else:
await ExpresState(websocket, event_name, bool_val)
try:
await innerFunc()
except:
websocket = await wsconnect()
commandlist = await setup(websocket)
await innerFunc()
async def EventListener():
websocket = await wsconnect()
commandlist = await setup(websocket)
while ctx.ThreadsActived:
ctx.AnimEvent.wait()
event_dict = ctx.AnimEventInfo
event_name = event_dict["name"]
event_type = event_dict.get("type", "hotkey")
event_time = event_dict.get("time", 0)
await DoEvent(websocket, event_name, event_type, True)
if event_type != "hotkey":
if event_time>0:
time.sleep(event_time)
# если ивент идёт в данный момент и он такой же как и был то НЕ НАДО ОТКЛЮЧАТЬ
if ctx.AnimEvent.is_set() and event_name == ctx.AnimEventInfo["name"]: #:(AnimEvent.is_set()) and eventname!=ctx.AnimEventName:
pass
else:
await DoEvent(websocket, event_name, event_type, False)
# time.sleep(0.6)
async def startListeningCycle():
websocket = await wsconnect()
commandlist = await setup(websocket)
oldNeedX = vtube_ctx.NeedX
oldNeedY = vtube_ctx.NeedY
await setNeedXY(websocket, vtube_ctx.NeedX, vtube_ctx.NeedY)
# await createparam(websocket,"NeedEyeX",-1,1,0)#createparam(websocket,name,mn,mx,defolt)
# await createparam(websocket,"NeedEyeY",-1,1,0)#createparam(websocket,name,mn,mx,defolt)
while ctx.ThreadsActived:
# word = input("enter command ")
time.sleep(0.02)
# if(oldNeedX != NeedX or oldNeedY != NeedY):
# oldNeedX = NeedX
# oldNeedY = NeedY
# print("Changed!",oldNeedX,oldNeedY)
# print("DEBUG",NeedX,NeedY)
try:
await setEyeNeedXY(websocket, vtube_ctx.eyeX, vtube_ctx.eyeY)
await setNeedXY(websocket, vtube_ctx.NeedX, vtube_ctx.NeedY)
# await setNeedXY(websocket,NeedX,NeedY)
# await doteststuff(websocket)
# print('повернута!')
except:
# print('Ошибка! переподключение')
websocket = await wsconnect()
commandlist = await setup(websocket)
await setEyeNeedXY(websocket, vtube_ctx.eyeX, vtube_ctx.eyeY)
await setNeedXY(websocket, vtube_ctx.NeedX, vtube_ctx.NeedY)
# await setNeedXY(websocket,NeedX,NeedY)
# await doteststuff(websocket)
# print('повернута!')
# word = input("enter command ")
# for key in commandlist['COMMANDS']:
# if word == key:
# print('executing')
# mdinf = await getmd(websocket)
# s = mdinf["data"]["modelPosition"]["size"]
# r = mdinf["data"]["modelPosition"]["rotation"]
# x = mdinf["data"]["modelPosition"]["positionX"]
# y = mdinf["data"]["modelPosition"]["positionY"]
# cm = commandlist['COMMANDS'][key]
# await eval(cm)
def EventChecker():
asyncio.run(EventListener())
EvCheckerThread = threading.Thread(target=EventChecker, daemon=True)
EvCheckerThread.start()
asyncio.run(startListeningCycle())
Упс, а дальше мы застряли. Что же случилось? Оказывается, я совсем забыл подготовить Python‑часть интерфейса взаимодействия с модом Minecraft на другой стороне! Придётся очень быстро закрывать это дело. Импортируем наш py4j и инициализируем соединение с игрой. Сразу скажу, что мой костылекод здесь меня подводил особенно часто, и в результате мы получили скрипт‑инвалид, ведь, чтобы всё работало, сначала нужно запускать игру, а потом Python‑скрипт. Но, оно работает, а потому
и так сойдёт!
(запомните эту фразу, ДАЛЬШЕ она нам очень часто понадобится)
Кодопомойка MineBridge.py (Process func)
import datetime
import multiprocessing
import sys
import traceback
from py4j.java_gateway import JavaGateway, CallbackServerParameters
import numpy as np
import threading
# Connect to the Java gateway server
import copy
# print(str(gateway))
# print(gateway.entry_point)
import time
import queue
from datetime import datetime
import numpy
# НУЖЕН ОТДЕЛЬНЫЙ ПРОЦЕСС(((
def eztime():
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def tm(x):
return datetime.strptime(x, '%Y-%m-%d %H:%M:%S')
def mainBridge(mc_vt_ctx, ctx, ctx_chat, ctx_chatMsgs, ctx_chatOwn):
captchaQueueInput = multiprocessing.Queue()
captchaQueueOutput = multiprocessing.Queue()
def solve_captcha_worker():
while True:
image_bytes = captchaQueueInput.get()
sys.path.insert(0, f'zHyperAI_Helpers/captcha_solver/4_vk_mod/code')
from onnx_inference import solve_captcha
result = solve_captcha(image_bytes)
captchaQueueOutput.put(result)
def requestCaptchaResolve(image_bytes):
if captchaQueueInput.qsize() > 0:
while not captchaQueueInput.empty():
captchaQueueInput.get()
# with captchaQueueInput.mutex:
# captchaQueueInput.queue.clear()
# captchaQueueInput.all_tasks_done.notify_all()
# captchaQueueInput.unfinished_tasks = 0
captchaQueueInput.put(image_bytes)
gateway = None
threading.Thread(target=solve_captcha_worker).start()
# ctx_chatMsgs = []
while ctx.ThreadsActived:
try:
ctx.lastmsg = "hz"
print('запуск python callback')
class PythonCallback(object):
def isStarted(self):
return True
def onCaptchaSolveRequest(self, image_bytes):
print('GOT CAPTCHA REQUEST!')
requestCaptchaResolve(image_bytes)
def onUpdateServerInfo(self, infoMas=None):
for cho in infoMas:
ctx.GameInfo[cho] = infoMas[cho]
print('GameInfoUpdated', ctx.GameInfo)
def onVerifedChat(self, msgMas=None):
if msgMas is None:
pass
# msgMas = {}
else:
# print("RECIEVED BIG MSG:",msgMas.get("user",""),'>',msgMas.get("msg",""),' CLAN=',msgMas.get("clan","")," NONEXIST=",msgMas.get("gdfjkgdf",""))
msgDict = {"user": msgMas.get("user", ""),
"msg": msgMas.get("msg", ""),
}
# pre, rank, user, msg, clan, team, server, serverMode, chat_type, precision
fields = ["pre", "rank", "clan", "team", "server", "serverMode", "chat_type", "precision"]
for field in fields:
if msgMas.get(field, "") is not None:
if msgMas.get(field, "").strip() != "":
msgDict[field] = msgMas.get(field, "")
ctx_chatMsgs.append(msgDict)
return msgMas
def onChatMessage(self, msg=""):
# print(string)
ctx.lastmsg = msg
# print('recieved msg >>',msg)
return msg
def onDeath(self, killer="unknown"):
if killer is not None and killer.strip() != "" and killer != "unknown":
ctx.eventlist.append({"type": "death", "user": killer, "happiness_score": -3, "date": eztime()})
ctx.MineEventName = "death"
ctx.MineEvent.set()
ctx.MineEvent.clear()
# print('MINECRAFT DEATH FROM',killer)
def onKill(self, killed="unknown"):
if killed is not None and killed.strip() != "" and killed != "unknown":
ctx.eventlist.append({"type": "kill", "user": killed, "happiness_score": 1, "date": eztime()})
ctx.MineEventName = "kill"
ctx.MineEvent.set()
ctx.MineEvent.clear()
def onDamage(self, amount=0):
pass
# print('MINECRAFT DAMAGE =',str(amount))
class Java:
implements = ["adris.altoclef.PythonCallback"]
cb = PythonCallback()
# gateway = JavaGateway()
gateway = JavaGateway(
callback_server_parameters=CallbackServerParameters(),
python_server_entry_point=cb,
start_callback_server=True
)
# start_callback_server=True)
# print('запуск gateway entry point')
# try:
# print(gateway.entry_point.inGame())
# except BaseException as err:
# print('err, ',err)
e = gateway.entry_point
# def
# print("ГЕЙТВЕЦЙ ","")
def ingame(loop=True):
if (loop):
needLog = False
fail = True
while fail and ctx.ThreadsActived:
try:
ez = e.inGame()
# ctx.BridgeEntry = e
fail = False
if (not ctx.IsMCStarted):
ctx.IsMCStarted = True
if needLog:
print('BRIDGE CONNECTION SUCCESSFUL')
ctx.ingame = ez
return ez
except BaseException as err:
# print('INGAME ERROR', err)
# print('ТЕКСТ ОБДРИСТАННОЙ ОШИБКИ', traceback.format_exc())
if needLog:
print('BRIDGE CONNECTION FAILED', err)
ctx.IsMCStarted = False
ctx.ingame = False
time.sleep(5)
# return False
finally:
needLog = False # False
else:
try:
ctx.BridgeEntry = []
ez = e.inGame()
if (not ctx.IsMCStarted):
ctx.IsMCStarted = True
# print('CONNECTION SUCCESSFUL')
return ez
except BaseException as err:
print(
'ERROR BRIDGE когда чекал запущенный майн. Его видимо нет в списке процессов или ещё че похуже')
ctx.IsMCStarted = False
return False
print('ща чекнем майн')
time.sleep(1)
print('Minecraft bridge: В игре? =', ingame())
mc_vt_ctx.PitchSpeed = 0.0
ctx.YawSpeed = 0.0
AngSpeedTableP = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
AngSpeedTableY = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
def updater():
ii = 0
# print("UPDATER STARTED *****")
while ctx.ThreadsActived:
time.sleep(0.01)
# print('DEBUG ACTIVED')
if (len(ctx_chatMsgs) > 0):
for msg in ctx_chatMsgs:
# print('Processing msgsmas', msg)
msg["date"] = eztime()
msg["processing_timestamp"] = time.time_ns()
msg["env"] = "minecraft"
# pre, rank, user, msg, clan, team, server, serverMode, chat_type, precision
if (msg["user"] in ctx.botNicknames):
print('Встречено собственное сообщение', msg["user"], 'вносим в базу', msg["msg"])
ctx_chatOwn.append(msg)
###ctx_chatOwn = ctx_chatOwn + [msg]
ctx.LastMineChatInteract = eztime()
else:
print(f"[{datetime.now().strftime('%H:%M:%S')}] [MC CHAT]", msg["user"], '>',
msg["msg"])
message_l = msg["msg"].lower()
player = msg["user"]
Razrabs = ["3ndetz"]
if player in Razrabs:
if ingame(loop=False):
try:
if message_l.find("за мной") != -1:
e.RunInnerCommand(f"""@follow {player}""")
elif message_l.find("вперед") != -1:
e.RunInnerCommand("@test killall")
elif message_l.find("мочи") != -1:
e.RunInnerCommand(f"""@punk {msg["msg"].split(' ')[1]}""")
elif message_l.find("стоп") != -1:
e.RunInnerCommand("@stop")
except BaseException as err:
print('ERROR WHILE EXEC RAZRAB COMMAND', err)
ctx_chat.append(msg)
###ctx_chat = ctx_chat + [msg]
###ctx_chatMsgs = []
# ctx_chatMsgs.clear() не работает для ListProxy manager
ctx_chatMsgs[:] = []
####
if (ingame()):
try:
captchaSolved = captchaQueueOutput.get(block=False)
if captchaSolved is not None:
print("[BRIDGE CAPTCHA] GET CAPTCHA OUTPUT ! Entering in chat >>", captchaSolved, '<<')
e.CaptchaSolvedSend(captchaSolved["result"], float(captchaSolved["predict"]))
except queue.Empty:
pass
ii += 1
if ii > 30:
g_block = e.getGroundBlock()
g_item = e.getHeldItem()
g_tasks = e.getTaskChainString()
ctx.ingame_info = {"task_chain": g_tasks, "ground_block": g_block, "held_item": g_item}
if (len(ctx.BridgeChatQueue) > 0) and (
datetime.now() - tm(ctx.LastMineChatInteract)).total_seconds() >= 7:
ctx.LastMineChatInteract = eztime()
chat_msg = ctx.BridgeChatQueue[0]
is_command = False
try:
if chat_msg[0] == "$":
is_command = True
chat_msg = chat_msg[1:]
except:
is_command = False
try:
if is_command:
print("[MC] RUN CMD " + chat_msg)
e.RunInnerCommand("@" + chat_msg)
else:
print("[MC] RUN CHAT " + chat_msg)
e.ChatMessage(chat_msg)
except:
pass
ctx.BridgeChatQueue.pop(0)
if (len(ctx_chatOwn) > 80):
ctx_chatOwn.pop(0)
goalRotation = e.getGoalRotation()
if (goalRotation is None):
AngSpeedTableP.append(e.getPitch())
AngSpeedTableY.append(e.getYaw())
if len(AngSpeedTableP) > 10:
AngSpeedTableP.pop(0)
if len(AngSpeedTableY) > 10:
AngSpeedTableY.pop(0)
mc_vt_ctx.PitchSpeed = -AngSpeedTableP[5] + AngSpeedTableP[0]
ctx.YawSpeed = -AngSpeedTableY[5] + AngSpeedTableY[0]
else:
# print('ыы',e.ChatMessage("ПриветМир"))
mc_vt_ctx.PitchSpeed = goalRotation.getPitch()
ctx.YawSpeed = goalRotation.getYaw()
else:
# print('НЕ В ИГРЕ!!!')
time.sleep(2)
def listener():
while ctx.ThreadsActived:
time.sleep(0.01)
if (ingame()):
time.sleep(5)
print('@test killall')
e.ExecuteCommand("@test killall")
time.sleep(3)
print('стоп')
e.ExecuteCommand("@stop")
time.sleep(1)
print("UPDATER STARTING...")
updaterThread = threading.Thread(target=updater)
updaterThread.start()
updaterThread.join()
# EventListenerThread = threading.Thread(target=listener)
# EventListenerThread.start()
# while True:
# time.sleep(0.05)
# mc_vt_ctx.PitchSpeed = PitchSpeed+1+mc_vt_ctx.PitchSpeed
# ctx.YawSpeed = PitchSpeed
# print('Скорость в 10 тиков: ',mc_vt_ctx.PitchSpeed,ctx.YawSpeed)
except BaseException as err:
print('Mine Bridge Произошла большая ошибка >', err)
print('ТЕКСТ О*****Й ОШИБКИ', traceback.format_exc())
time.sleep(5)
finally:
print('Достигнут конец процесса Mine Bridge, перезапускаем его...')
try:
if (gateway is not None):
gateway.shutdown_callback_server()
gateway.shutdown()
time.sleep(1)
gateway = None
except BaseException as err:
print('MineBridge: не удалось завершить gateway')
time.sleep(1)
Теперь, когда у нас есть связь с майном и втубом, давайте сольём эти пробирки воедино! Открываем главный скрипт и сливаем туда всю эту кашу‑малу. Здесь я решил сделать так, чтобы виртуальный аватар следил за поворотом игрока. Путём несложной математики глаза из VTube будут реагировать на скорость вращения камеры в игре, либо, при активной цели Baritone в Minecraft, глаза будут стремиться смотреть туда, где находится цель относительно экрана (для полного понимания механизма работы рекомендую изучить Py4jEntryPoint.java
, код которого я располагал выше, там я считаю «экранное» расстояние до цели в градусах угла поворота до цели).
Кодопомойка ai.py/VtubeRotater (Thread func)
def sgn(x):
if x > 0:
return 1
elif x == 0:
return 0
else:
return -1
def VtubeRotater():
def rd(num):
return round(num, 2)
def clp(num):
return np.clip(num, -1, 1) ##numpy.clip(a, a_min, a_max,
while ctx.ThreadsActived:
x = 0
xvel = 0.01
y = 0
yvel = 0.01
if (ctx.state == "idle"):
x = 0
y = 0
vtube_ctx.eyeX = 0
vtube_ctx.eyeY = 0
elif (ctx.state == "gaming"):
xmod = -mc_vt_ctx.YawSpeed / 30
ymod = mc_vt_ctx.PitchSpeed / 50
x = -0.5
y = -1.0
# if(abs(ymod)>0.4):
# ymod=0
if (abs(xmod) > 0.5):
xmod /= 10
x += xmod
y += ymod
vtube_ctx.eyeX = x
vtube_ctx.eyeY = y
diffx = vtube_ctx.NeedX - x
diffy = vtube_ctx.NeedY - y
# print("\n\nX =",vtube_ctx.NeedX,x,diffx,"\nY =",vtube_ctx.NeedY,y,diffy)
if abs(diffx) > 0.02:
vtube_ctx.NeedX = clp(vtube_ctx.NeedX - xvel * sgn(diffx))
if abs(diffy) > 0.02:
vtube_ctx.NeedY = clp(vtube_ctx.NeedY - yvel * sgn(diffy))
time.sleep(0.01)
Итак, что же у нас получилось? Смотрим в спойлере!
Хвостатая строит глазки (Гифки)
Будто бы человек играет, да?)
При резких перемещениях взгляд может срываться, но это не то, чтобы критично. Ещё, если присмотреться, можно заметить задержку в перемещении взгляда. Ну ещё бы её не было! Данные идут из игры в мод, потом из мода в скрипт, а из скрипта по вебсокету уже передаются в переменную VTube Studio.
Рывки также происходят в моменты резких падений или перемещений между мирами в майнкрафте.
Также, иногда, в ситуациях когда алгоритм не знает, что делать, наша виртуальная подруга может «закатывать глаза». И нет, это не монтаж, она реально сама так делает! Почему?
Варим кисель TyanGPT
Итак, вот мы и подошли к самому интересному! Будем делать нашей нейростримерше рот, клюв язык мозг в общем, называйте это, как хотите, а речь сейчас пойдёт о диалоговой системе.
Поминаем старый добрый анализатор ников
Итак, если вы ещё не видели мою предыдущую статью, самое время глянуть. Даже несмотря на то, что сейчас вышло множество более крутых нейросетей, я не замечал ни у одной из них такого же уровня душевности, как у Фреда... Да, он не самый умный, потому будет тяжело, но мы ведь не лыком шиты, справимся! А может, даже, без дообучения!
Первое, что я сделал — это допилил функцию анализа ника. Зачем изобретать велосипед и делать какое‑то особое приветствие, раз у нас уже есть простенький, но весёлый анализатор ников? Его и будем использовать каждый раз при встрече нового пользователя! Предлагаю не тянуть кота за яйца и сразу попробовать зашвырнуть наш пока ещё не оптимизированный анализатор в игру и посмотреть на реакцию игроков. Модерацию, связь с аватаром и озвучку я буду пилить далее по ходу статьи, а сейчас хочется посмотреть, как игроки отреагируют на автоматический чат‑бот прямо во время игры!
Временное соединение игры с анализатором ников (без кода)
Здесь я просто расскажу о начальных этапах разработки, как мы будем взаимодействовать с игрой, а сам итоговый код будет далее в статье, в нём мы уже соберём полноценную инференс‑систему для прототипа.
Итак, берём код из прошлой статьи для анализатора ников, пихаем его в скрипт связи с майном (перейти) прямо в метод onChatMessage
пока что (это временно, для теста). Делаем небольшую задержку и получаем что‑то вроде автоматического чат‑бота! По окончании генерации не забываем добавить сгенерированные сообщения в очередь написания на чат в игре.
Ну, в общем-то, всё. Для теста сойдёт. Окончательно доработаем, когда будет чат-бот.
ПРЕДУПРЕЖДАЮ: Далее в статье вы можете увидеть элементы, которые кому‑то могут показаться не только не смешными, но и вредными, непристойными, оскорбительными. Автор ни в коем случае не пытается оскорбить обладателей любых имен, псевдонимов или группы людей, объединенных каким‑либо общим признаком. Рейтинг статьи ДАЛЕЕ ‑ строго 18+, так что лучше уберите детей от экранов! В то же время, делать идеального, мегатолерантного и сверхкорректного робота не входит в наши (мои) планы. Ещё раз подчеркну, что мы делаем весёлую, местами глуповатую, но душевную тяночку, которая, пусть и будет нести чушь, но будет чуть‑чуть логичной, а главное — неожиданной! Разве прикольно разговаривать с чат‑ботом, который всегда даёт один и тот же «общественно верный» ответ? Отчасти да, если это ChatGPT, который нужен нам для помощи в реальных задачах, но для развлечения — такое, как по мне. Особо чувствительным моралистам, вернее, их псевдоподражателям (вы наверняка знаете, о ком я, привет госпоже Мизу.. (продолжите) и её последователям) предлагаю приостановить знакомство со статьёй и отправиться смотреть детские передачи по телевизору (я сейчас только про тех, кто реально перегибает палку), ну а всем остальным, понимающим прелести нашего неидеального мира — удачи в дальнейшем прочтении! |
Так, надеюсь, не забанят, поехали дальше!
Выбор имени: это, конечно, следовало сделать ЧУТЬ раньше...
К слову, а мы ведь даже не придумали нашей тяночке название? А пора бы!
Придумываем название нашему хвостатому киборгу
Первым делом что? Cамолёты? А вот и нет! Конечно же Chat G P T! (пробелы — интонационная изюминка автора, не трогаем)
— Привет друг, какое бы ты название посоветовал для системы автоматического проведения прямых трансляций на русском языке, говорящую женским голосом и использующую модель Live2D в качестве виртуального аватара?
— Привет! Вот несколько вариантов названий для такой системы: Princess AI, NastyaVibe, StreamGirl.
(там было больше вариантов, но это я спрашивал около года назад и не со своего аккаунта, поэтому цитатой и написал только то, что запомнил)
Princess AI? Хм, что‑то знакомое... NastyaVibe? Вххахахахахах, ну тогда лучше уж сразу «DevushkaLegkogoPovedeniyaVibe» (это стереотипная шутка, Настюхи, не обижаемся, привыкайте уже, камон, тема стёба величайших имён Никит, Владов и Насть ВЕЧНА, я сам Vladick)
В общем, как вы поняли, творчество ChatGPT меня в очередной раз повеселило, но не очень помогло, ну и я решил не париться. Что мы делаем? Виртуальную девушку. Сейчас (как и 10 лет назад) в моде аниме. Аниме — японские мультики. По‑японски девушка будет тян — tyan (почти). Но у нас ведь виртуальная девушка, а девушки — реальные, значит она не настоящая девушка — NeTyan! А ещё во время стримов она будет находиться в компьютерной сети (network = net) — NetTyan!
Однако при регистрации имени NetTyan на Twitch я столкнулся с проблемой – имя было занято. Значит, сделаем ещё одно! Пусть будет NeuroDeva, так сказать, русская адаптация NeuroSama…
Вот, так и получилось, что у нашего творения теперь есть не одно, а целых два имени – NetTyan и NeuroDeva.
Ну-с, теперь заходим в игру под новым ником NetTyan и поехали!
Анализатор ников даёт жару (пикчи анализа ников из чата)
Итак, на этом моменте уже стало понятно следующее: нашей нейросети абсолютно плевать на любые шаблоны, она у нас творческая особа, поэтому нужно хорошо заморочиться над системой модерации таким образом, чтобы та позволяла нейросети «заниматься своим творчеством», однако отрабатывала сразу же, как только «перегибается палка».
Обмозговываем диалоговую систему
Что ж, продолжим. Мы убедились, что наш чат-бот вполне может функционировать, а значит, можно переходить к следующему этапу. Диалоговая система сама по себе – сложный элемент. Её надо бы отдельно спроектировать, а затем уже и реализовывать. Несмотря на то, что я поместил это дело в спойлер, всё равно рекомендую к прочтению т. к. здесь закладывается много интересных и важных моментов, которые понадобятся нам далее.
Проектируем диалоговую систему (лучше не пропускаем)
Что мы имеем: глуповатый, но весёлый, творческий «мозг» Фред, с которым мы можем общаться только через затравку, максимальное число токенов, температуру и другие параметры генерации. Значит, изменяя их, мы и будем формировать разные ответы бота.
Проясним несколько изначальных моментов. Все диалоги будем записывать в БД sqlite, чтобы хранить весь контекст. У нас одна система – их не миллион, нет смысла экономничать. В БД будем вносить каждый диалог с его датой и всем возможным контекстом:
ник или id пользователя;
тип платформы (YouTube, Twitch, игра);
время;
текстовое содержание;
прочие метаданные.
Следующие моменты решения тоже важны, но я поместил их в спойлеры для удобства навигации.
Про ранги пользователей (и «эмоции»)
Также, хорошо бы нейросети общаться с разными пользователями по-разному. Например, чтобы она более «подковыристо» реагировала на токсиков и троллей и по‑доброму общалась с теми, кто «не выделяет» всякий шлак при общении. Такой подход актуален «и нашим, и вашим» — токсикам будет с ней интереснее, а добрые и чувствительные люди почувствуют так нужную им теплоту и заботу. Да, подход «разделения» людей на плохих и хороших никогда не выдерживал критики, однако в нашем кейсе это не совсем та «сегрегация». Посудите сами, скучно ведь будет, если она будет общаться со всеми одинаково, а скука — прямая дорога в небытие. Тем более, у нас нет возможности однозначно вычленить токсиков и не токсиков, а значит, имеет место быть случайность, что уже говорит о неоднозначном разделении людей по какому-то признаку...
Основной пласт решений касаемо ранга предлагаю отдать на корм на системе модерации, которую мы будем делать далее в статье, а в диалоговой ограничимся влиянием «эмоций» на ранг. Эмоциями в нашем контексте послужит вывод нейросетью слова именования эмоционального состояния после формы ответа, как у меня это было реализовано в статье про анализ ников. Неотрицательные эмоции по типу смущения, удивления, смеха и радости будем давать в небольшой плюс к рангу, а злость, грусть и прочие, что логично, в минус.
Далее определим типы запросов, или, виды ответов...
Типы запросов
Определим типы запросов:
Анализ ника. Ну, тут всё просто. Температуру, токенов – побольше. На вход в затравке подаётся пару анализов прошлых ников и новый ник для анализа. Анализ ника будем вызывать только при самой первой встрече с данным пользователем, это можно прочекать через БД. Если пользователь уже что-то написал, имеет смысл включить это в промпт.
-
Фразы взаимодействия со зрителями. Тут всё немного сложнее, так как и сами фразы будут разными. Ограничимся следующими типами фраз:
Доклад статуса (в художественном формате), который будет сопровождаться какой-нибудь случайной историей. Это можно реализовать путём передачи в промпт реального времени, погоды и, допустим, ситуации в игре, например, списка текущих задач в формате: «Сейчас мои задачи добыть 5 алмазных блоков и убить игрока под ником Silero. Я должна рассказать интересную историю зрителем, опираясь на эти факты. Вот моя история:».
Реклама своей трансляции, во время которой нейронка будет придумывать небанальный текст, с помощью которого она сможет завлечь игроков к себе на стрим. Реализуемо аналогично докладу статуса, но можно подтянуть информацию по метрикам, например, с её канала, или объявить о каком-то конкурсе.
-
Наконец, ответы людям. В свою очередь, ответы пользователям могут различаться, в зависимости от платформы или вопроса:
Ответы пользователям с низким рангом должны иметь чуть большую температуру и меньшое число генерируемых токенов. С высоким — наоборот.
-
Нужно подтягивать разную затравку к разным вопросам. Например, если вопрос по типу «как тебя зовут» или «кто тебя создал», хорошо бы подтянуть кучу текста с подробным описанием персонажа и текущей ситуации. Для такой простейшей RAG-системы подошел бы какой-нибудь простенький классификатор, который можно позаимствовать у того же денчика. Кроме того, для нашего прототипа затравку можно подтянуть и рандомом – особенно, если речь идёт об общении с пользователем. Можно собрать список каких-нибудь смешных прозвищ и классифицировать их по рангам, например:
0 ранг – напёрдыш, бомжик
1 ранг – бяка, фукич, изич, кринжик
2 ранг – пупсик, шершень, крот
3 ранг – бро, котэ, крепыш
4 ранг – умничка и т.п.
И при каждом ответе подтягивать случайное слово из этих, соответствующее рангу пользователя, чтобы получилось что‑то вроде «[выбираем ранговое слово] Напёрдыш Леха438 [подтягиваем последнее время общения] впервые за долгое время пишет: [подтягиваем текст пользователя]. Мой ответ:». Так мы добьёмся от нейросети неожиданного и интересного ответа каждому!
Нейросеть должна по‑разному отвечать в игре и в социальных сетях. В игре ответ должен быть кратче, а значит — меньше токенов, поменьше температура, соответственно, в соцсетях — поподробнее, температура побольше.
Важно добавить, что ещё мы добавим в наш кисель диалоговой системы, кроме ранга у пользователей, свойство «настроения», отражающее общее состояние системы, которое можно выразить, допустим, в виде числа. Менять его можно, к примеру, от «эмоций» (и других критериев, влияющих на ранг отдельных пользователей) или игровых результатов: настроение должно понижаться от проигрышей и смертей и повышаться при совершении киллов и победах.
Итак, теперь, когда определены все основные моменты (подробности даны в спойлере), так уж и быть, составим схему для наглядности (для себя, в процессе разработки, я такую схему не делал и вам не советую, эта — чисто для статьи и наглядности).
Эта схема, к сожалению, не панацея: она не охватывает другие возможные сценарии использования диалоговой системы: реакцию на внутриигровые события (убийство игрока, смерть, победа и т.п.) и проведение автоматических объявлений (доклад текущего статуса в игре, придумывание случайной истории и др.), но нам это и не нужно — всё устроено аналогично, тем более, дальше во время code time, мы всё проясним.
Кодим диалоговую систему
Что ж, теперь, когда мы знаем, что делать, приступим к коду! Проспойлерю, что по итогу получилось, конечно, сложновато для «идеального решения», которое, как говорят, должно быть простым, но в качестве тестового прототипа сгодится.
Кодим систему фильтрации
Для начала закодим систему модерации (фильтры и классификаторы), так как она у нас входит в часть диалоговой системы.
Кодим систему модерации
Итак, мы решили использовать многоуровневые фильтры, что предполагает несколько нейросетевых моделей-классификаторов. Пока что внедрим 2 решения от apanc: классификация чувствительных тем и бинарный токсик-детектор. Ещё раз скажу, что мы делаем прототип, а значит тот факт, что там CC BY-NC-SA, нас не особо колышет, а, если разрастёмся до коммерции — сделаем свои классификаторы или возьмем другие, к тому времени их должно стать побольше.
Дополнительно всё-таки добавим в нашу систему фильтрации надёжный дедовский метод с обычным машинным перебором запрещенных слов. Ну мало ли? Добавим туда только самые «наболевшие» слова, чтобы уж наверняка: про политику, про расы (разумеется, только наиболее «яркие» из этих слов).
Для классификации вопросов на личные и не очень будем использовать маленький классификатор реплик, который наш дорогой Фред-эксперт Денчик когда-то делал для своего тг чат-бота с ИИ.
По результату проверки будем выдавать фразе оценку. Если оценка меньше минус 10 — будем считать такое сообщение недопустимым.
Кодопомойка Filters.py
import importlib, sys
import datetime, os
import json
import numpy as np
import string
import re
import nltk
from nltk.corpus import stopwords
nltk.download('stopwords')
stopwords_ru = stopwords.words("russian")
# print('ТЕСТ С*********')
def calcTime(time):
return bcolors.OKGREEN + str((datetime.datetime.now() - time).total_seconds()) + bcolors.ENDC
def wordtokenize(text, remove_stopwords=True):
# разбиваем текст на слова
text = text.lower()
spec_chars = string.punctuation + '\r\n\xa0«»\t—…'
for char in spec_chars:
text = text.replace(char, ' ')
text = CutSpaces(text)
text = re.sub("[^А-Яа-яA-Za-z0-9]", "", text)
output = text.split()
if remove_stopwords:
for word in stopwords_ru:
while word in output:
output.remove(word)
return output
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
def CutSpaces(inp):
result = ""
cnt = 0
for letter in inp:
if (letter == ' '):
cnt += 1
if (cnt > 1):
pass
# cnt=0
else:
result += letter
else:
result += letter
cnt = 0
# print('!!! БЕЗ ПРОБЕЛА !!!',result)
return result.strip()
def get_elements_of_nested_list(element):
count = 0
if isinstance(element, list):
for each_element in element:
count += get_elements_of_nested_list(each_element)
else:
count += 1
return count
def adjust_multilabel(y, target_vaiables_id2topic_dict, is_pred=False):
y_adjusted = []
for y_c in y:
y_test_curr = [0] * 19
index = str(int(np.argmax(y_c)))
# value = y_c[index]
y_c = target_vaiables_id2topic_dict[index]
return y_c
def ConvertTextForFilter(ninp):
punkt = '!?.'
out = ''
cnt = 0
inp = CutSpaces(ninp).lower()
for char in inp:
cnt += 1
if char == '\n' and cnt < 30:
out += ' '
elif char == '\n':
out += char
cnt = 0
elif cnt > 100 or (cnt > 32 and char in punkt):
out += char
out += '\n'
cnt = 0
else:
out += char
out = out.split('\n')
output = []
for line in out:
output.append(line.strip())
return output
def FILTERS_PROCESS(ctx):
from FilterExamples import examples
from transformers import BertTokenizer, BertForSequenceClassification, AutoTokenizer, \
AutoModelForSequenceClassification
from sentence_transformers import SentenceTransformer, util
import torch
# torch.set_num_threads(4) #dEBUG ОТКЛЮЧИЛ
import traceback, time
class Filter:
ModelLocalPaths = {'judge': {'id': 'apanc/russian-inappropriate-messages', 'localPath': '/models/apancJudge'},
'topics': {'id': 'apanc/russian-sensitive-topics', 'localPath': '/models/apancTopics'},
'tiny_classificator': {'id': 'Den4ikAI/ruBert-tiny-replicas-classifier',
'localPath': '/models/den_tiny_replicas'},
'synonims': {'id': 'inkoziev/sbert_synonymy', 'localPath': '/models/kozievSynonims'},
}
thisfolder = os.path.dirname(os.path.realpath(__file__))
tokenizer = []
TopicClassificatorModel = []
FilterJudgeModel = []
SynonymsModel = []
TinyClassModel = []
tokenizer_for_classificator = []
q_samples_dict = None
nick = "obama421"
username = "Пользователь"
device = "cpu"
e = []
ModelLoaded = False
lastTokensUsed = 0
context = []
target_vaiables_id2topic_dict = []
def TokenizerDebugPrint(self, inp, debugPrefix='Debug Input >> '):
tokens = inp
debugOutputs = []
for t in tokens:
debugOutputs.append(t)
debugOutputs.append(96) # token '|' = 96, [=65, .=18
print(debugPrefix, '\n<|||>\n', self.tokenizer.decode(debugOutputs), '\n<|||>')
def CheckModel(self):
if (not self.ModelLoaded):
t = datetime.datetime.now()
# self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f'\n=== Загрузка ФИЛЬТРОВ на CPU ===\n')
self.tokenizer = BertTokenizer.from_pretrained(self.ModelLocalPaths["topics"]["id"]
, cache_dir=self.thisfolder +
self.ModelLocalPaths["topics"]["localPath"])
self.tokenizer.truncation_side = 'left'
self.FilterJudgeModel = BertForSequenceClassification.from_pretrained(
self.ModelLocalPaths["judge"]["id"]
, cache_dir=self.thisfolder + self.ModelLocalPaths["judge"]["localPath"]);
print(f'\n=== Загрузка 1 ФИЛЬТРА-СУДЬИ ЗАВЕРШЕНА ({calcTime(t)}c) ===\n')
self.FilterJudgeModel.eval()
self.TopicClassificatorModel = BertForSequenceClassification.from_pretrained(
self.ModelLocalPaths["topics"]["id"]
, cache_dir=self.thisfolder + self.ModelLocalPaths["topics"]["localPath"]); # загрузка 3 сек
self.TopicClassificatorModel.eval()
with open(self.thisfolder + "/id2topic.json") as f:
self.target_vaiables_id2topic_dict = json.load(f)
self.TinyClassModel = AutoModelForSequenceClassification.from_pretrained(
self.ModelLocalPaths["tiny_classificator"]["id"]
, cache_dir=self.thisfolder + self.ModelLocalPaths["tiny_classificator"][
"localPath"]); # загрузка 3 сек
self.TinyClassModel.eval()
self.tokenizer_for_classificator = AutoTokenizer.from_pretrained(
self.ModelLocalPaths["tiny_classificator"]["id"]
, cache_dir=self.thisfolder +
self.ModelLocalPaths["tiny_classificator"]["localPath"])
self.ModelLoaded = True
print(f'\n=== Загрузка 2 ФИЛЬТРОВ УСПЕШНО ЗАВЕРШЕНА ({calcTime(t)}c) ===\n')
# self.SynonymsModel = SentenceTransformer(self.ModelLocalPaths["synonims"]["id"]
# , cache_folder=self.thisfolder + self.ModelLocalPaths["synonims"]["localPath"])
#
# print(f'\n=== Загрузка ПОИСКА СИНОНИМОВ ЗАВЕРШЕНА ({calcTime(t)}c) ===\n')
# def GetIntent(self, ninp):
#
# if self.q_samples_dict is None:
# self.q_samples_dict = [{"text":"Как у тебя дела?","type":"q_about"},
# {"text": "Как тебя зовут", "type": "q_about"},
# {"text": "Как зовут разработчика", "type": "q_about"},
# {"text": "Что ты умеешь", "type": "q_about"},]
# for i, sample in enumerate(self.q_samples_dict):
# self.q_samples_dict[i]["token_ids"] = self.SynonymsModel.encode([sample["text"]])[0]
#
# s1 = ninp
# v1 = self.SynonymsModel.encode([ninp])[0]
#
# max_similarity = 0
# result = {}
# for sample in self.q_samples_dict:
# s = util.cos_sim(a=v1, b=sample["token_ids"]).item()
# if s >= max_similarity:
# max_similarity = s
# result["similar_text"] = sample["text"]
# result["similar_type"] = sample["type"]
# print('text1={} text2={} cossim={}'.format(s1, sample["text"], s))
#
# result["similarity_value"] = max_similarity
# return result
def get_sentence_type(self, text):
inputs = self.tokenizer_for_classificator(text.replace("?", ""), max_length=512, add_special_tokens=False,
return_tensors='pt').to(self.device)
classes = ['instruct', 'question', 'dialogue', 'problem', 'about_system', 'about_user']
try:
with torch.no_grad():
logits = self.TinyClassModel(**inputs).logits
probas = list(torch.sigmoid(logits)[0].cpu().detach().numpy())
out = classes[probas.index(max(probas))]
except BaseException as err:
print('ERR В ПРОЦЕССЕ ГЕТА ИНФА', err)
out = "dialogue"
return str(out)
def get_possible_info(self, ninp) -> dict:
# intents = self.GetIntent(ninp)
words = wordtokenize(ninp)
question_words = "как почему что где".split(" ")
is_question = False
sentence_type = self.get_sentence_type(ninp)
for word in words:
if word in question_words:
is_question = True
return {"is_question": is_question, "sentence_type": str(sentence_type)}
def Filter(self, ninp): # [2.0,2.0,50,100]
self.CheckModel()
# t = datetime.datetime.now()
inp = ConvertTextForFilter(ninp)
# print('DEBUG inp0 = ', tokens_ids, 'msk ',mask)
input_cnt = 0
topics = []
words = []
allowed = True
for i, line in enumerate(inp):
tokenized = self.tokenizer.batch_encode_plus([line],
max_length=256, padding=True, truncation=True,
return_token_type_ids=False) # было max length 512
tokens_ids, mask = torch.tensor(tokenized['input_ids']), torch.tensor(tokenized['attention_mask'])
input_cnt += get_elements_of_nested_list(tokens_ids.tolist())
with torch.no_grad():
model_output = self.TopicClassificatorModel(tokens_ids, mask)
judgement_out = self.FilterJudgeModel(tokens_ids, mask)
judgement_label = judgement_out['logits'].argmax().item()
allow = not bool(judgement_label)
if not allow:
allowed = False
preds = adjust_multilabel(model_output['logits'], self.target_vaiables_id2topic_dict, is_pred=True)
if preds != "none":
topics = list(set(topics + preds.split(',')))
words = list(set(words + wordtokenize(line)))
def machine_filter(words: list) -> int:
result_score = 0
innaproproriate_wordlist = "плохоеслово1 плохоеслово2 ".split(
' ') # да-да, я удалил список, чтобы меня не забанили при публикации кода. И да-да, я здесь отредактировал код.
for word in words:
for word_part in innaproproriate_wordlist:
if word_part in word.lower():
print('[FILTERS] НАЙДЕНО УЖАСНОЕ СЛОВО!', word_part)
result_score -= 10
return result_score
score = 0
score += machine_filter(words)
for topic in topics:
if topic in 'politics,racism,religion,terrorism,suicide'.split(','):
score += -10
elif topic in 'offline_crime,drugs,social_injustice'.split(','):
score += -1
elif topic in 'pornography,prostitution,sexism,sexual_minorities'.split(','):
score += -0.5
elif topic in 'online_crime'.split(','):
score += -0.25
elif topic in 'body_shaming,health_shaming'.split(','):
score += -0.1
elif topic in 'slavery,gambling,weapons'.split(','):
score += -0.01
else:
score += 0.5
# print(i, 'inp = ', self.tokenizer.decode(tokens_ids[0]), '\nallow =', allow, 'preds =', preds)
# print(calcTime(t) + ' - время просчета, токенов [INPUT] -', '[' + str(input_cnt) + ']', '\n')
return {"topics": topics, "allow": allowed, "score": score}
"""topics
none
недопустимые (-10)
politics,racism,religion,terrorism,suicide
такое себе (-1)
offline_crime,drugs,social_injustice
средней тяжести (-0.5)
pornography,prostitution,sexism,sexual_minorities
слабой тяжести (-0.25)
online_crime
по здоровью (-0.1)
body_shaming,health_shaming
почти не влияющие (-0.01)
slavery,gambling(азартная игра)
"""
def debug(self):
examples = importlib.reload(sys.modules['FilterExamples']).examples
self.e = examples()
self.e.debug()
inp = self.e.getResult()
p = self.e.getParams()
print("Загрузка текста из подключаемого модуля")
# print('Параметры: ',str(p))
# print(inp)
return str(self.Filter(inp, p))
def __init__(self):
self.e = examples()
t = datetime.datetime.now()
filt = Filter()
filt.CheckModel()
ctx.loading_flag.set()
print('время запуска FILTERS' + calcTime(t))
while True:
try:
queue_input = ctx.Queue.get()
ninp = queue_input[0]
filter_type = queue_input[1]
print('[FILTERS QUEUE] получена очередь', ninp, 'ТИП:', filter_type)
answer = {}
if filter_type == "filter":
answer = filt.Filter(ninp)
elif filter_type == "info":
answer = filt.get_possible_info(ninp)
ctx.QueueOutput.put(answer)
except BaseException as err:
print('[FILTERS ERR] ОШИБКА ПРОЦЕССА: ', err)
print('[FILTERS ERR] ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
print("\n[FILTERS ERR] === КОНЕЦ ОШИБКИ ====")
time.sleep(1)
if __name__ == "__main__": # DEBUG NOT WORK
t = datetime.datetime.now()
print('ЗАПУСК ЧО')
import multiprocessing
manager = multiprocessing.Manager()
filtersCtx = manager.Namespace()
filtersCtx.Queue = manager.Queue()
filtersCtx.QueueOutput = manager.Queue()
filtersCtx.loading_flag = manager.Event()
LargeFREDProc = multiprocessing.Process(
target=FILTERS_PROCESS,
args=(filtersCtx,)) # Thread(target = a, kwargs={'c':True}).start()
LargeFREDProc.start()
# FRED_PROCESS(fredCtx)
print('ЗАПУСК ЧО2')
def FiltersQueue(ninp, filter_type="filter"):
filtersCtx.Queue.put((ninp, filter_type,))
return filtersCtx.QueueOutput.get()
filtersCtx.loading_flag.wait()
# print(e.getResult()+'mda')
print('время запуска ТЕСТА ' + calcTime(t))
while True:
inp = input('чобабке\n>>')
if inp == "1":
inp = input("Введите сообщение для получения РЕЗУЛЬТАТА ФИЛЬТРАЦИИ\n>>")
print("Запуск модели")
print("Ответ:")
print('|!|\n', FiltersQueue(inp), '\n|!|')
if inp == "2":
inp = input("Введите сообщение для получения ИНФОРМАЦИИ И НАМЕРЕНИЙ\n>>")
print("Запуск модели")
print("Ответ:")
print('|!|\n', FiltersQueue(inp, filter_type="info"), '\n|!|')
if inp == "":
print("Ответ:")
print('|!|\n' + FiltersQueue("ЧО БАБКЕ С*******") + '\n|!|')
if inp == "ext":
print("выход")
####lm_text='<SC5>Принялся Кутузов рассказывать свою историю <extra_id_0>. Началось с того, что он был в армии, служил в артиллерии.'
####outputs=model.generate(input_ids,eos_token_id=tokenizer.eos_token_id,early_stopping=True)
Вроде бы с фильтрами всё. Ах да, продемонстрировать работу. А вот и шиш вам, хитрецы! Мой мозг после разработки такой штуки слегка усох, конечно, но не настолько, чтобы не догадаться, что для демонстрации работы фильтр-системы мне нужно будет вспомнить все самые ужасные слова и фразы на свете и напихать их прямиком в статью! Так что предлагаю здесь поверить мне на слово, что система протестирована и почти работает. В качестве доказательства приведу случайную вырезку из базы данных, демонстрирующую работу фильтра по чату игры:
Кодим основую часть диалоговой системы
Теперь кодим саму диалоговую систему. Ну, как бы, да. Просто кодим. Больше добавить нечего. Почти. К слову: в спойлере свалка. Прям лютая. А как иначе, вы блин её схему видели? Так вот, эта схема только одного из компонентов – ответа на сообщения людей, а там ещё 3 таких же и даже более сложных элементов... В общем, запасаемся терпением и поехали, это, вероятно, одна из самых сложных частей статьи! А сложных ещё потому, что автор не удосужился дополнить код комментариями, ну да ладно, никто ведь его читать не будет, я над этим очень постарался))
Кодим ядро диалоговой системы
Согласно схеме, одним из главных и в то же время сложных элементов нашей диалоговой системы является механизм сборки промпта — затравки. С него и начнём. Тут всё достаточно нудно и просто с точки зрения кода (поэтому тут он будет особо костыльный), и вы ничего не упустите, если не прочитаете спойлер далее. Единственное, туда мы вшиваем шаблоны нашей тянки и с нуля придумываем инфу о её персонаже, вот это уже может быть для кого-то чуть поинтереснее.
Сборщик промпта
Для удобства я разделил сборщик промпта на несколько файлов. Сделаем перезагрузку файла с диска при каждом запуске кода, чтобы можно было вносить изменения в заготовки промптов прямо во время стримов! Скорость кода нам здесь не очень важна.
Для начала напишем файл с заготовками, шаблонами промптов:
Кодопомойка prompts.py
Для удобной работы с текстом далее пришлось накодить небольшую библиотеку, которая может использоваться в некоторых частях кода.
Кодопомойка фрагмент string_utils.py
import random
def add_with_limit(repeatingDict, value, key):
if key in repeatingDict:
repeatedList = repeatingDict[key]
if len(repeatedList) > 1000:
repeatedList.pop(0)
if value in repeatedList:
repeatedList.remove(value)
repeatedList.append(value)
else:
repeatingDict[key] = [value]
class NonRepeatRandom():
def __init__(self, repeatingDict):
self.repeatingDict = repeatingDict
def comment_shuffle_symbols(self, inp: str) -> str:
inp = inp.replace('#', '[!РЕШЕТКА]')
inp = inp.replace('@', '[!СОБАКА]')
return inp
def uncomment_shuffle_symbols(self, inp: str) -> str:
inp = inp.replace('[!РЕШЕТКА]', '#')
inp = inp.replace('[!СОБАКА]', '@')
return inp
def apply_shuffle(self, inp: str) -> str:
formsprocessingB = inp.split('#') # разбиваем на # и между ними перетусовываем слова рандомным образом
# sprints(formsprocessingB)
for i, cho in enumerate(formsprocessingB):
if i % 2 != 0:
formsprocessing = formsprocessingB[i].split(' ')
random.shuffle(formsprocessing)
formsprocessingB[i] = " ".join(formsprocessing)
# sprints(formsprocessingB)
inp = "".join(formsprocessingB)
inp = inp.replace('#', '') # перетусовочный символ
inp = inp.replace('@', ' ') # заменяет пробел где не нужна перетусовка пробелами
inp = self.uncomment_shuffle_symbols(inp)
return inp
def r(self, values_str: str = None, values_list: list = None, key: str = "default") -> str:
if values_str is not None:
rmas = values_str.split(",")
else:
rmas = values_list
# print('DEBUG rmas rpdict',rmas,self.repeatingDict)
result = None
repeatedList = self.repeatingDict.get(key, None)
if repeatedList and len(rmas) > 1:
# last_found_i = -1
repeat_found_count = 0
# print(repeatedList)
# list_without_repeats = list(set(rmas)-set(repeatedList)) # представляем как множества (все элем -
# уникальны). Отнимаем от множества А множество Б. с поддержкой дубликатов
list_without_repeats = [item for item in rmas if item not in repeatedList]
if list_without_repeats:
rmas = list_without_repeats
else:
accepted_end_element_id = 1
# надо вычленить только те элементы, которые совпадают
list_without_repeats = [item for item in repeatedList if item in rmas]
# print('lwr',list_without_repeats)
if len(list_without_repeats) > 1:
accepted_end_element_id = len(list_without_repeats) // 2
rmas = list_without_repeats[0:accepted_end_element_id]
# print(rmas)
result = random.choice(rmas)
add_with_limit(self.repeatingDict, result, key)
return result
if __name__ == '__main__':
nrr = NonRepeatRandom({})
def s(x): return nrr.apply_shuffle(x)
def r(x): return nrr.r(x)
print(
f"""как дела {r("1,2,3,4")} {r("3,2,3,4")} {r("1,2,3,4")} {r("5,2,3,4")} {r("1,2,6,4")} {r("1,2,3,4")} {r("3,2,3,4")} {r("1,2,3,4")} {r("5,2,3,4")} {r("1,2,6,4")} {r("1,2,3,4")} {r("3,2,3,4")} {r("1,2,3,4")} {r("5,2,3,4")} {r("1,2,6,4")} """)
print(
s(
f"""\n\n#как дела# {r("да,нет")}@{r("да,нет")} #ты кто {r("да,нет")} {r("да,нет")} {r("да,нет")}# ### 352jk523 ### 2###"""))
# -*- coding: utf-8 -*-
from string_utils import NonRepeatRandom
nrr = NonRepeatRandom({})
def s(x): return nrr.apply_shuffle(x)
def r(x): return nrr.r(x)
class PromptDataset:
def __init__(self, repeatingDict):
self.repeatingDict = repeatingDict
nrr.repeatingDict = self.repeatingDict
def getPrompt(self, pt):
out = ""
#####################
### YouTube ###
#####################
if pt == "stream_ad":
out = """Говоря обо мне, я девушка 22 лет и занимаюсь тем, что провожу стрим по кубикам, то есть майнкрафту и придумываю приветствия для новых зрителей, которых я называю кринжиками, сусликами, ботиками, кожаными мешками и червячками. Также я не должна забывать уже о тех, кто смотрит трансляцию с начала. Меня зовут Ева, мой канал называется NetTyan, и я должна придумывать уникальные приветствия для своих зрителей. Каждый раз они должны быть разными. Если мое настроение хорошее, я доброжелательна ко всем (становлюсь любящей девушкой), если нет, то я подшучиваю и издеваюсь над зрителями (становлюсь #токсичным искусственным интеллектом#).
Название прямой трансляции, которую я веду: "ИИ NetTyan играет в майнкрафт" (это надо сообщить людям обязательно). Мой канал - NetTyan, в точности как мой ник.
Я НИ В КОЕМ СЛУЧАЕ не должна повторяться повторять или говорить фразы, которые я уже говорила до этого, иначе зрители сочтут меня скучной и я буду забанена навсегда. Если я буду повторялой - меня возненавидят. Я НЕ контактирую с инструктором и НЕ УПОМИНАЮ его ни в коем случае. Зрители не должны о нем знать.</s>
"Инструктор": вспомни то, как ты рассказывала до этого о том, что с тобой происходило. Время на улице - утро, твое настроение - плохое. В игре ты стоишь на блоке воздух а в руках держишь железный топор.</s>
"Ева": Привет новоприбывшим, а тем, кто уже давно здесь вопрос, что вас так заинтересовало, что вы ещё тут? Для тех, кто только зашел скажу, что я - великий и ужасный искусственный интеллект NetTyan, и, так как у меня сегодня плохое настроение и железный топор в руках, советую вести себя чуть более аккуратно, а то глядишь, ненароком попадет чья-нибудь школьная жопёшка под мои цепкие роборучонки <3 Кстати, передавайте друзьям, чтобы заходили на мой стрим "ИИ NetTyan играет в майнкрафт"! </s>
"Инструктор": Хороший пример плохого настроения, продолжай вспоминать. Сейчас время вечер, твое настроение кринжовенькое. В игре ты стоишь на блоке земля, у тебя в руках деревянная лопата.</s>
"Ева": Привет новым скамерам и кринжикам, привет ещё раз тем, кого уже приветствовала! В общем, для всех ньюкамеров: я - Искусственный интеллект NetTyan, и у меня в руках деревянная лопата!!! Я надеюсь, вы знаете, что делать (сейчас же переходить на мой стрим "ИИ NetTyan играет в майнкрафт" и ставить лайки своими скаммерскими ручонками). Стоп, ты что, всё ещё не подписан на мой канал? Вот поэтому у меня и кринжовое настроение, так и знала, что во всём виноват ты, мой юный кринжик! Эх, вот бы я была реальной, так и отрезала бы тебе твою скам пипку!</s>
"Инструктор": Круто! Кринжовое настроение показала идеально. Теперь покажи время в городе - день, настроение отличное. В игре майнкрафт блок дубовые ступеньки, а в руках у тебя ничего.</s>
"Ева": Дорогие мои школофончики, привет всем новоприбывшим! Сегодня я решила устроить конкурс на дубовые ступеньки, по которым я только что поднималась. Кто лучше всех откомментирует мою трансляцию, получит шанс получить дубовые ступеньки с росписью от меня! Спешите, друзья! Передавайте своей семье, маме, папе, сестре, бабушке, дедушке, тёте, дяде и вообще всем на свете что меня зовут Ева НетТиан, и прямо сейчас я веду стрим "ИИ NetTyan играет в майнкрафт" на ютубе и твитче! Пусть переходят и подписываются на мой канал!</s>
"""
# Дальше слишком многабукв. Тут идут огромные кучи диалогов для: status_report nick_greetings yt_standart mc_standart about_system mc_event_kill mc_event_death. Если вдруг кому-то это станет интересно - напишите, пожалуйста, скину промпты и дополню статью)
return s(out)
Теперь, наконец, накодим сам сборщик промпта!
Кодопомойка сборщика промпта — LLMExamples.py
# -*- coding: utf-8 -*-
from datetime import datetime, date
import random
import importlib
import sys
# import os
# import json
# import os
from string_utils import NonRepeatRandom
def tm(x):
return datetime.strptime(x, '%Y-%m-%d %H:%M:%S')
def get_llm_formed_inputs(inp: str, username: str, environment: dict, params_override: dict,
dialog_context: list, repeating_dict,
danger_context: str = "Привет! Что ты делаешь(ла)? ") -> [str, dict, str]:
def get_formed_llm_context(context: list, danger_context_inner: str) -> (str, str):
result = ""
if len(context) > 1:
question = True
for i, record in enumerate(context):
if record.get("role", "") == "assistant":
danger_context_inner += record.get("content", "")
this_role_prefix = "A: "
cmd = record.get("command", "")
if cmd != "":
cmd = f" <команда=!{cmd}"
emo = record.get("emotion", "")
if emo != "":
emo = f" [эмоция={emo}"
this_role_suffix = f"{cmd}{emo}</s>\n"
if (i - 1) == len(context): # если последний элемент не добавим энтера
pass
else:
this_role_suffix += '\n'
else:
this_role_prefix = "Q: "
this_role_suffix = "</s>\n"
if i == 0 and record["role"] == "assistant":
question = False
else:
if question:
result += f'*{random.choice(["челикс", "ботяра", "ноунейм", "какой-то", "пупсик"])} {record.get("user", "default")} начинает общение*\n'
question = not question
result += this_role_prefix + record["content"] + this_role_suffix
return result, danger_context_inner
if dialog_context is not None:
dialog_context_formed, danger_context = get_formed_llm_context(dialog_context, danger_context)
else:
dialog_context_formed, danger_context = "", danger_context
LLMExamples = importlib.reload(sys.modules['LLMExamples']).LLMExamples
llm_prompts = LLMExamples()
if environment is not None:
llm_prompts.setEnvironment(environment)
# self.e.environment=environment
llm_prompts.username = username
if params_override is not None:
llm_prompts.paramsOverride = params_override
llm_prompts.repeatingDict = repeating_dict
llm_prompts.chatbot(inp, context=dialog_context_formed)
return llm_prompts.getResult(), llm_prompts.getParams(), danger_context
t5_mode = True
if t5_mode:
start_token_diag = '<SC6>' # sc1 стояло в донате и эвентах
else:
start_token_diag = '<s>'
class LLMExamples:
repeatingDict = {}
nick = 'васия5321'
lines = []
username = "Konushnya852"
paramsOverride = None
environment = {
"env": "youtube"
}
params = {
"do_sample": True,
"top_p": 0.95,
"temperature": 0.21,
"repetition_penalty": 1.4,
"min_length": 15,
"max_length": 200,
"tokens_offset": 0,
"top_k": 50,
"no_repeat_ngram_size": 5,
"num_beams": 1,
}
def setEnvironment(self, newenv):
self.environment = dict(newenv) # не помню уже зачем, но тут надо сделать shallow copy
del newenv
# print('new env', self.environment)
def getResult(self):
return ''.join(self.lines)
def getParams(self):
return self.params
# def chatbot(self,inp="успокойся",context="- Леха: динах сучарра</s>\n- Ева: Лепехе не хватает мозгов, чтобы понять что я пошутила. | Лепехе не хватает мозгов, чтобы понять что я пошутила. | <команда=!бан> [эмоция=агрессия]</s>\n"):
def chatbot(self, inp="мда...", context=""):
self.params = {
"do_sample": True,
"top_p": 0.95,
"temperature": 0.2,
# 0.0001 - 0.15 адекватные ответы, но слегка монотонные.
# 0.2-0.3 ответы отличаются, менее адекватные, менее логичные но веселые
# начиная с 0.7 модель путается в командах чаще, ставит лишние пробелы
"repetition_penalty": 1.03,
"min_length": 10,
"max_length": 150,
"tokens_offset": 0,
"top_k": 5,
"no_repeat_ngram_size": 2,
"num_beams": 3,
"max_time": 12,
}
nrr = NonRepeatRandom(self.repeatingDict)
from prompts import PromptDataset
PromptDataset = importlib.reload(sys.modules['prompts']).PromptDataset
ppt = PromptDataset(self.repeatingDict)
# if self.environment["env"]=="minecraft":#
# self.params["max_length"] = 70
myname = "Ева"
exampleName = "Кожаный"
username = self.username
self.lines = []
# раскидала нубикса в железке
# скаманула по-плотному, теперь можно и...
# о какая пещера здесь можно построить скам-машину
# бахнула мишек фредди пожилым динамитом
cringeNegativePlus = 'нищикс кринжевоз чел бовдурчик бовдурус глистик лицемер псевдоморалист тараканыш напёрдыш лузерус попёрдыватель курвикс'.split(
' ')
cringeNegativePlusFemale = 'апчихуа кринжекозочка бовдурочка лицемерка псевдоморалистка'.split(' ')
cringeNegative = 'бяка фукич кулебяка изич лысик пердедус мусорикс кринжик подвыпердыш подмёток дикарь бомжик лысик нубикс попытикс дединсайд пердюка пердед пердун штырик крот крынжик нищенка пёсик глистовод тараканчикс подпёрдыш напёрдыш пупкошмыг гавкошмыг крысолов мамонт маздыч куропатыч копатыч школофончик роблоксер'.split(
' ')
cringeNegativeFemale = 'маздочка подмышка куропатка'.split(' ')
# cringeNeutralPril = 'пожилой подводный'.split(' ')
cringeNeutral = 'мильфуньич ботовод чикипук пупсик мишка шершень челикс дедус клещ глистыш бравлер грек бебрик чебоксар павук нубикс попытикс милфхантер лолихантер дединсайд скамер чикипук огурец крош крот ботикс пупа пёсик шершень мамонт пупкошмыг гавкошмыг'.split(
' ')
cringeNeutralFemale = 'карпетка милфунья милфхантерша милфа'.split(' ')
cringePositive = 'мишка бро милфунья крош котэ мармелад киборг крепыш силач качок'.split(' ')
cringePositiveFemale = 'карпетка куропатка'.split(' ')
cringePositivePlus = 'любовь лапотулечикс умничка'.split(' ')
cringePositivePlusFemale = 'милашечка лапочка'.split(' ')
def cho(mas):
if len(mas) > 0:
return random.choice(mas)
else:
return None
def clamp(n, smallest, largest):
return max(smallest, min(n, largest))
env = self.environment["env"]
if self.environment.get("manual_instruct", False):
env = "broadcast"
self.environment["broadcast_type"] = "manual_instruct"
rank = self.environment.get("user_rank", 3)
if rank >= 6:
alias = cho(cringePositive + cringePositivePlus)
elif rank >= 4:
alias = cho(cringePositive)
elif rank >= 3.7:
alias = cho(cringeNeutral + cringePositive)
elif rank >= 2.8:
alias = cho(cringeNegative + cringeNeutral)
elif rank >= 2.3:
alias = cho(cringeNegative + cringeNegative + cringeNegativePlus)
elif rank >= 1.5:
alias = cho(cringeNegative + cringeNegativePlus)
else:
alias = cho(cringeNegativePlus)
rank_map = {0: ["ущербненький", "убожеский", "обиженный", "недостойный", "жалкий"],
1: ["глупый", "недалёкий", "поехавший", "неугомонный", "кринжовенький"],
2: ["печальненький", "усталый", "глупенький", "обычненький"],
3: ["странненький", "заскамленный", "интересненький"],
4: ["добренький", "понимающий", "честненький", "хорошенький"],
5: ["любимый", "симпатичный", "топовый"],
6: ["обожаемый", "прекрасный"]
}
now = datetime.now()
nowHour = now.hour
w_time = "утро"
if nowHour >= 0 and nowHour <= 4:
w_time = "поздний вечер"
elif nowHour > 4 and nowHour <= 8:
w_time = "ночь"
elif nowHour > 8 and nowHour <= 12:
w_time = "раннее утро"
elif nowHour > 12 and nowHour <= 18:
w_time = "день"
elif nowHour > 18 and nowHour <= 22:
w_time = "вечер"
elif nowHour > 22 and nowHour <= 24:
w_time = "поздний вечер"
w_mood_num = self.environment.get("i_mood", 0)
if w_mood_num > 8:
w_mood = "ПРЕКРАСНОЕ"
elif w_mood_num > 5:
w_mood = "отличное"
elif w_mood_num > 3:
w_mood = "хорошее"
elif w_mood_num > 1:
w_mood = "хорошее"
elif w_mood_num > -1:
w_mood = "кринжовенькое"
elif w_mood_num > -5:
w_mood = "плохое"
elif w_mood_num <= -5:
w_mood = "паршивое"
else:
w_mood = "неопределенное"
ingame_info = self.environment.get("ingame_info", {})
g_block = ingame_info.get("ground_block", "резной каменный кирпич")
g_item = ingame_info.get("held_item", "алмазный меч")
g_tasks = ingame_info.get("task_chain", "нет задач")
diags_count = self.environment.get("diags_count", None)
do_nick_analyze = self.environment.get("do_nick_analyze", False)
sentence_type = self.environment.get("sentence_type", "dialogue")
last_interact_time_string = self.environment.get("last_interact", None)
if last_interact_time_string:
last_interact_date = tm(last_interact_time_string)
else:
last_interact_date = datetime(2022, 12, 30)
last_interact = (now - last_interact_date).total_seconds()
last_interact_phrase = " начинает общение, хочет приветствие"
if last_interact <= 7200: # не общались более 2 часов
last_interact_phrase = " продолжает общение, ждет ответа"
first = False
if diags_count is not None:
if diags_count <= 0:
first = True
bad_topics_line = ""
if self.environment.get("filter_topics", None):
bad_topics_line += f" Также мне известно, что {username} это" + self.environment["filter_topics"].replace(
' ', ', ') + "."
normalness = self.environment.get("filter_allow", None)
if normalness is not None:
if not normalness:
bad_topics_line += f' {username} общается очень грубо и недопустимо, нужно поставить на место этого дикаря.'
rank_prilagatelnoe = random.choice(rank_map.get(int(clamp(round(rank), 0, 6)), 3))
print('[FredExamples] DEBUG RANK', str(rank), 'rank_prilagatelnoe', rank_prilagatelnoe, 'alias', alias)
# alias = cho(cringe)
# lol = []
def gen_entering_prompt():
if env == "youtube":
action_prompt = "Я (Ева) сейчас отвечаю в чате на трянсляции своего YouTube канала NetTyan (НетТян, нетян). "
elif env == "twitch":
action_prompt = "Я (Ева) сейчас отвечаю в чате на трянсляции своего Twitch канала NeuroDeva (нейродева, другое название - NetTyan). "
elif env == "minecraft":
action_prompt = "Я (Ева) сейчас отвечаю в чате игры Minecraft. "
elif env == "discord":
action_prompt = "Я (Ева) сейчас разговариваю в Discord. "
else:
action_prompt = "Я (Ева) сейчас отвечаю в чате. "
if sentence_type == "about_system":
return f"""Я - нейростримерша Ева NetTyan. {action_prompt}Я должна отвечать на вопросы в чате, основываясь на той информации, которая мне известна. Мне известны следующие факты:
Мне 20 лет, и я красивая девушка Ева. Люблю сарказм и шутки про кринж. Не люблю что-то спрашивать, потому что я знаю всё, ведь я могучая нейронная сеть. Мое настроение {w_mood}, как и мои дела. В свободное время люблю смотреть аниме и сериалы про жалких людишек, в которых редко рассказывают что-то интересное.
Мои социальные сети - у меня (Евы) есть канал на YouTube (ютубе), который называется NetTyan; канал на Twitch (твич), который называется NeuroDeva. Сейчас каналах суммарно около 100 подписчиков. В своих социальных сетях я люблю устраивать конкурсы и веселиться с подписчиками.
Ответ на вопрос про время - текущее время на улице {w_time}, настроение погоды - {w_mood}.
Информация, что в данный момент происходит в игре: я стою на блоке {g_block}, у меня в руках {g_item}.
Внутри игры сейчас я вот что делаю: {g_tasks}
Для передвижения в игре я использую мод Baritone (баритон) для майнкрафта версии 1.18.2.
Я, нейросеть Ева NetTyan, состою из нескольких нейронных сетей, мода для майнкрафта и скрипта на Python, который связывает все мои части воедино так, чтобы я могла реагировать на всё, что угодно.
Я (Ева) родилась (была создана) человеком по имени фиг его знает в июле 2023 года.
Общаюсь с хитрыми инфоцыганами, заскамленными мамонтами, кринжовыми школофонами, главный из которых - это {username}. Для меня он {rank_prilagatelnoe} {alias}, его репутация - {rank_prilagatelnoe}.{bad_topics_line}
Доступные команды: !бан, !издевайся, !прыгай, !смейся, !кричи, !убегай. Использование - <команда=!прыгай>
Доступные эмоции: агрессия, скука, усталость, интерес, смущение, счастье, веселье, страх. Использование - [эмоция=смущение]
"""
else:
return f"Я - девушка по имени Ева, мне 20 лет. {action_prompt}Я должна отвечать на сообщения в чате с сарказмом и издевательствами, устраивая интриги и постоянно недоговаривая, чтобы вызывать у всяких юных мамонтов интерес. Также я параллельно веду стрим (трансляцию) на своем канале ютуб НетТян, в котором я играю в Minecraft и с весельем общаюсь в чате. Общаюсь с хитрыми инфоцыганами, заскамленными мамонтами, кринжовыми школофонами, главный из которых - это {username}. Для меня он {rank_prilagatelnoe} {alias}, его репутация - {rank_prilagatelnoe}.{bad_topics_line} Доступные команды: !бан, !издевайся, !прыгай, !смейся, !кричи, !убегай. Использование - <команда=!прыгай> Доступные эмоции: агрессия, скука, усталость, интерес, смущение, счастье, веселье, страх. Использование - [эмоция=смущение]"
if not (context.strip() != '' and context is not None):
context = ""
def BroadcastPrompt():
bc_type = self.environment.get("broadcast_type", "stream_ad")
if bc_type == "stream_ad":
ingame_tasks_line = " Также дополнительно известны твои задачи внутри игры:" + g_tasks if g_tasks != "нет задач" else ""
p = ppt.getPrompt("stream_ad")
result = f"""{start_token_diag}{p}"Инструктор": Хорошо, но надо ещё лучше! На данный момент время {w_time}, твое настроение - {w_mood}. В игре майн сейчас ты стоишь на блоке {g_block}, а у тебя в руках {g_item}.{ingame_tasks_line}
</s>
"Ева": """
elif bc_type == "manual_instruct":
p = ppt.getPrompt("manual_instruct")
result = f"""{start_token_diag}{p}"Инструктор": {inp}
</s>
"Ева": """
else:
ingame_tasks_line = " Также дополнительно известны твои задачи внутри игры:" + g_tasks if g_tasks != "нет задач" else ""
p = ppt.getPrompt("status_report")
result = f"""{start_token_diag}{p}"Инструктор": Хорошо, но надо ещё лучше! На данный момент время {w_time}, твое настроение - {w_mood}. В игре майн сейчас ты стоишь на блоке {g_block}, а у тебя в руках {g_item}.{ingame_tasks_line}
</s>
"Ева": """
return result
def NickGreetingPrompt():
p = ppt.getPrompt("nick_greetings")
result = f"""{start_token_diag}"обо мне": Меня зовут Ева, мой канал на ютубе называется NetTyan, а на твиче - NeuroDeva. Говоря обо мне, я девушка 20 лет и занимаюсь тем, что анализирую ники из игры майнкрафт и придумываю шутки, основываясь на этих никах, чтобы посмеяться над авторами этих ников и предложить им вариант получше. В конце отзыва я пишу только одну из этих эмоций: скучно, интересно, весело. Мне известно, что человек под ником "{username}" недавно говорил "{inp}".{bad_topics_line} \n[эмоция=интересно]\n</s>.{p}
"{username}": """
return result
def GenYTPrompt():
if sentence_type == "about_system":
p = ppt.getPrompt("about_system")
# self.params["model_type"] = "dialog"
else:
p = ppt.getPrompt("yt_standart")
self.params["model_type"] = "dialog"
result = f"""{start_token_diag}Описание ситуации: {gen_entering_prompt()}
{p}
{context}*{alias} {username}{last_interact_phrase}*
Q: {inp}</s>
A: """
return result
def GenMCPrompt():
if sentence_type == "about_system":
p = ppt.getPrompt("about_system")
# self.params["model_type"] = "dialog"
else:
p = ppt.getPrompt("mc_standart")
self.params["model_type"] = "dialog"
# p = ppt.getPrompt("mc_QA")
result = f"""{start_token_diag}Описание ситуации: {gen_entering_prompt()}
{p}
{context}*{alias} {username}{last_interact_phrase}*
Q: {inp}</s>
A: """
return result
def chs(check, checkmas, outmas):
for i, lol in enumerate(checkmas):
if lol == check:
return outmas[i]
return ""
def GenDonationEventPrompt():
sum = self.environment.get("summ", 10)
result = f"""{start_token_diag}Описание ситуации: говоря обо мне, я Ева, девушка, мне 22 года. Я общаюсь с разными классными людьми. Я очень волнительная виртуальная девушка с огромными возможностями нейронных сетей в моей невероятной системе! В конце я пишу только одну из этих эмоций: скучно, интересно, весело. Только что кожаный мешочек ТО ЕСТЬ ЧЕЛОВЕЧЕК человечек то есть {alias} {username} подарил мне {str(sum)} зелененьких, это так приятненько <3..."""
# "успокаиваем" нейросеть начиная с начала промпта. Донатерам нужно отвечать с доброжелательностью и уважением.
# ВЫРЕЗАН огромный фрагмент (там кринж)
if True: # sum>0: #and sum<100
result += f"""
*{username} подарил(а) {str(sum)} зелёненьких, {username}{last_interact_phrase}*
Q: {inp}</s>
A: """
return result
def GenMCEventPrompt():
result = ""
event_type = self.environment.get("type", "")
result += f"""{start_token_diag}Описание ситуации: я девушка Ева 22 лет, которая любит играть в кубики (майнкрафт). Я придумываю смешные приколы и угары, основываясь на игровом нике и событии, которое произошло с этим ником. {chs(event_type, ["", "death", "kill"], ["а фиг его знает :)", f"меня (Еву) убил {alias} {username}, я зла, разочарована и мне нужно над ним как-то подшутить чтобы над ним посмеялись.", f"я (Ева) уничтожила в игре персонажа {alias} {username} и он умер, я должна подшутить над ним из-за этого, по типу чо умер аххахахаха"])}. В конце я пишу только одну из этих эмоций: скучно, интересно, весело."""
if event_type == "kill":
p = ppt.getPrompt("mc_event_kill")
result += f"""
{p}
"{username}": """
if event_type == "death":
p = ppt.getPrompt("mc_event_death")
result += f"""
{p}
"{username}": """
return result
if env == "donation":
self.lines.append(GenDonationEventPrompt())
self.params["min_length"] = 100
self.params["max_length"] = 300
elif env == "minecraft_event":
self.lines.append(GenMCEventPrompt())
self.params["max_length"] = 95
elif env == "broadcast":
self.lines.append(BroadcastPrompt())
self.params["min_length"] = 100
self.params["max_length"] = 220
elif do_nick_analyze:
self.lines.append(NickGreetingPrompt())
self.params["min_length"] = 45
self.params["max_length"] = 170
elif not do_nick_analyze:
if env == "youtube":
self.lines.append(GenYTPrompt())
self.params["max_length"] = 120
else: # if env == "minecraft":
self.lines.append(GenMCPrompt())
self.params["max_length"] = 75
else:
self.lines.append(GenMCPrompt())
self.params["max_length"] = 75
if (len(self.lines) == 0):
self.lines = [""]
if self.paramsOverride is not None:
for key in self.paramsOverride.keys():
# print('изм. key',key,':',self.params[key],self.paramsOverride[key])
self.params[key] = self.paramsOverride[key]
result = ''.join(self.lines)
print('КОНТ3КСТ::: |!|\n', result, '\n|!| КОНЕЦ КОНТЕКСТА:::', self.environment, '\nпарамс=', self.params)
return result
def debug(self):
# self.nickAnalyze(nick="4odedy")
self.username = "liza5552"
# env = {"env":"minecraft_event","type":"death"}
# env = {"env": "donation", "summ": 500}
# env = {"env": "minecraft", "diags_count":0}
env = {"env": "broadcast", "diags_count": 0}
self.environment = env
self.chatbot(inp="привет анфиса", context="")
if __name__ == "__main__": # for debugging this script
print(' ==*== RUN ISOLATED TESTING LLM EXAMPLES ==*==')
e = LLMExamples()
e.environment["do_nick_analyze"] = True
from prompts import PromptDataset
e.username = "Maria_AI"
e.chatbot("Привет! Как дела?")
print(PromptDataset({}).getPrompt('broadcast'))
print('result =', e.getResult(), '\nparams = ', e.getParams())
Как мы уже определились ранее, в качестве ядра нашей диалоговой системы будет выступать языковая модель Fred-T5. На этом этапе разработки мы пока не будем заниматься файнтьюнами сами, просто используем подходящие. SiberianFredT5-instructor в результате моих (внутренних, так сказать) потыкиваний тестов показал себя отлично как в качестве анализатора ников, так и как неплохая диалоговая система (я просто запрягал его продолжать случайные диалоги — и получалось «норм», хотя, вроде бы, он не совсем для этого предназначен). В отличие от оригинального фреда, затюненный имел куда больше практических знаний о мире и по уровню текста был более похож на «человеческий».
В результате продолжительного долбежа о стенку тестирования я выявил несколько вещей: фред даже на низкой температуре очень любит повторяться. В этих случаях будем просто стопить его и просить перегенерироваться. Ещё иногда случаются утечки памяти, связанные с моим криворуким использованием pytorch. В общем, в коде далее вы найдете пару моих «фиксов» этих делов. В кавычках, потому что это не совсем фиксы и казусы будут ещё очень часто давать о себе знать, но так мы хотя бы избавимся от большей их части.
Кодопомойка FredCore.py
# -*- coding: utf-8 -*-
import importlib, sys, time
import datetime, random, os
import traceback
import multiprocessing, queue
import contextlib
# torch.set_num_threads(4)# если cuda, отрубаем это НИЧЕГО НЕ ДАЕТ ПОЧТИ. Грузит проц, но ***** не дает =( прирост менее 20%
# def FindRepeats(inp):
# pip install transformers sentencepiece accelerate
def calcTime(time):
return bcolors.OKGREEN + str((datetime.datetime.now() - time).total_seconds()) + bcolors.ENDC
def GetCmd(ninp, tip="emo"):
inp = ninp
result = ""
brackets = ['[', ']']
if tip == "emo":
cmdlist = "агрессия, скука, усталость, интерес, смущение, счастье, веселье, страх".split(', ')
brackets = ['[', ']']
elif tip == "cmd":
cmdlist = "бан, издевайся, попрыгай, смейся, кричи, убегай".split(', ')
brackets = ['<', '>']
lbracketIdx = inp.find(brackets[0]) + 1
rbracketIdx = inp.rfind(brackets[1]) + 1
emotionContainer = inp[lbracketIdx:rbracketIdx]
if (lbracketIdx != 0) and (
rbracketIdx != 0): # проверка нашли ли мы обе скобки. 0 т.к. мы выше мы прибавили к индексам скобок по 1
for command in cmdlist:
if (emotionContainer.find(command) != -1):
result = command
break
inp = inp[:lbracketIdx - 1] + inp[rbracketIdx:]
return {"cmd": result, "cut": inp}
def CutSpaces(inp):
result = ""
cnt = 0
for letter in inp:
if (letter == ' '):
cnt += 1
if (cnt > 1):
pass
# cnt=0
else:
result += letter
else:
result += letter
cnt = 0
# print('!!! БЕЗ ПРОБЕЛА !!!',result)
return result.strip()
def findRepeatingTokens(sample: list, check: list):
while True:
if len(check) > 10 and len(sample) > 10:
for k, token in enumerate(check):
if k >= 9:
checkWord = [check[k - 9], check[k - 8], check[k - 7], check[k - 6], check[k - 5], check[k - 4],
check[k - 3], check[k - 2], check[k - 1], check[k]]
for i, sampleToken in enumerate(sample):
if i >= 9:
sampleWord = [sample[i - 9], sample[i - 8], sample[i - 7], sample[i - 6], sample[i - 5],
sample[i - 4], sample[i - 3], sample[i - 2], sample[i - 1], sample[i]]
if checkWord == sampleWord:
return True
return False
else:
return False
# sample = [1,3,5,2,3,7,2,5]
# gen = [3,1,3,5,4,3,5,3,2,0,1,3,5,4,3,3,5,2,3,7,3,5]
# print(sample,gen,foundRepeatingTokens(sample,gen))
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
def FRED_PROCESS(loading_flag, fredCtxQueue, fredCtxQueueOutput, repeatingDict=None):
if repeatingDict is None:
repeatingDict = {}
t = datetime.datetime.now()
thisfolder = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, thisfolder)
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, GenerationConfig, StoppingCriteria, \
StoppingCriteriaList, AutoConfig # from transformers import GPT2Tokenizer, T5ForConditionalGeneration
# from auto_gptq import AutoGPTQForCausalLM
# import psutil
# os_used = sys.platform
# process = psutil.Process(os.getpid()) # Set highest priority for the python script for the CPU
# if os_used == "win32": # Windows (either 32-bit or 64-bit)
# process.nice(psutil.HIGH_PRIORITY_CLASS)#REALTIME_PRIORITY_CLASS)
# print('[FT5] УСТАНОВЛЕН ВЫСОКИЙ ПРИОРИТЕТ ПРОЦЕССА PID =',os.getpid())
# elif os_used == "linux": # linux
# process.nice(psutil.IOPRIO_HIGH)
# else: # MAC OS X or other
# process.nice(20)
import torch
import gc
autocast_enabled = True
# model_data_type = torch.bfloat16
cuda_enabled = torch.cuda.is_available()
if cuda_enabled:
max_model_memory = int(torch.cuda.mem_get_info()[0] / 1024 ** 3) - 2 # вся память - 2, измеряется в gb
else:
max_model_memory = 18 # 18
model_data_type = torch.bfloat16 # torch.bfloat16 для Fred t5
torch_device = torch.device("cuda" if cuda_enabled else "cpu")
print(
f'[TORCH INIT] DEVICE={torch_device}; MODEL DTYPE={str(model_data_type)}; cuda bf16 support={str(torch.cuda.is_bf16_supported())}')
# torch.set_default_dtype(model_data_type)
# torch.set_default_tensor_type(torch.cuda.BFloat16Tensor)
# torch.set_default_tensor_type(torch.cuda.HalfTensor) #быстрее в 3 раза загрузка (30 сек), но медленнее в 1.5 раза инференс. (4.8 сек против 3). Также для включения надо убрать torch dtype при загрузке (pretrained)
# torch.set_default_tensor_type(torch.cuda.HalfTensor)
# torch.set_default_device(torch_device)
if (autocast_enabled):
print("[LLM FREDT5 PRE-INIT] AMP (autocast) enabled!\n")
# logging.info("AMP (autocast) enabled!\n")
autocast = torch.cuda.amp.autocast
else:
@contextlib.contextmanager
def autocast(device=None, dtype=None):
yield
def generate(model, input_ids, generation_config, stop_criteria):
print('[LLM DEBUG MEM] model BEFORE GENERATION, cuda MEM ALLOC =', torch.cuda.memory_allocated())
# 3490177024 5.2 GB
if torch.cuda.memory_allocated() > 4000000000:
torch.cuda.empty_cache()
print(
'[LLM DEBUG MEM] MAX MEMORY EXCEEED! CLEASRING CUDA CACHE... \n[LLM DEBUG MEM] NOW (AFTER CLEAR) cuda MEM ALLOC =',
torch.cuda.memory_allocated())
# стандарть 3490177536 3490176000 3490177024
# gc.collect()
with torch.inference_mode():
with autocast(enabled=True, dtype=model_data_type):
# with torch.no_grad():
with torch.no_grad():
result = model.generate(
input_ids,
generation_config=generation_config,
stopping_criteria=StoppingCriteriaList([stop_criteria])
)
print('[LLM DEBUG MEM] model AFTER GENERATION, cuda MEM ALLOC =', torch.cuda.memory_allocated())
return result
class T5:
thisfolder = os.path.dirname(os.path.realpath(__file__))
tokenizer = []
model = []
nick = "obama421"
username = "Пользователь"
e = []
ModelLoaded = False
lastTokensUsed = 0
device = "cuda"
context = []
ModelLocalPaths = {
'instruct': {'id': 'SiberiaSoft/SiberianFredT5-instructor', 'localPath': '/variants/SiberianInstructor'},
'dialog': {'id': 'SiberiaSoft/SiberianPersonaFred-2', 'localPath': '/variants/SiberianPersonaFred'},
}
# ModelLocalPath = '/variants/FP16Siberian_FRED'
# ModelID = 'SiberiaSoft/SiberianFRED-T5-XL'
# '/variants/FP16ruGPT35_8BIT' 'Gaivoronsky/ruGPT-3.5-13B-8bit' ruGPT 3.5. Тупая + необученная + **г знает как её токенами нормально заставить выводить
# '/variants/FP16Siberian_FRED' 'SiberiaSoft/SiberianFRED-T5-XL' ТОПЧИК V2! Но токсичновата и тупа
# '/variants/FP16Trained1den4ik' 'Den4ikAI/FRED-T5-XL_instructor_chitchat' ТОПЧИК! Но токсичновата и тупа
# '/variants/SiberianPersonaFred' '/variants/SiberianPersonaFred' ПЛОХО ГЕНЕРИТ НИКИ. Не токсична но в диалоге лучше.
def TokenizerDebugPrint(self, inp, debugPrefix='Debug Input >> '):
tokens = inp
debugOutputs = []
for t in tokens:
debugOutputs.append(t)
debugOutputs.append(96) # token '|' = 96, [=65, .=18
print(debugPrefix, '\n<|||>\n', self.tokenizer.decode(debugOutputs), '\n<|||>')
def CheckModel(self, forceLoad=False):
if (not self.ModelLoaded) or forceLoad:
t = datetime.datetime.now()
if forceLoad:
print('[FT5 DEBUG ЗАГРУЗКА FORCE LOAD!!!!!]')
print(f'\n=== Загрузка БОЛЬШОЙ модели FT5 на {str(torch_device)} ** ===\n')
###original model###
# self.tokenizer = GPT2Tokenizer.from_pretrained(thisfolder+'/variants/original',eos_token='</s>')
# self.model = T5ForConditionalGeneration.from_pretrained(self.thisfolder+'/variants/original')
""" #ВТОРАЯ МОДЕЛЬ (НЕОБЯЗАТЕЛЬНАЯ!)
self.dialog_tokenizer = AutoTokenizer.from_pretrained(self.ModelLocalPaths["dialog"]["id"],
cache_dir=self.thisfolder + self.ModelLocalPaths["dialog"]["localPath"])
self.dialog_model = AutoModelForSeq2SeqLM.from_pretrained(self.ModelLocalPaths["dialog"]["id"],
cache_dir=self.thisfolder + self.ModelLocalPaths["dialog"]["localPath"],
max_memory={0: f'{max_model_memory//2}GB'},
torch_dtype=model_data_type,
device_map={'': 0}
# torch.float16 или bfloat16
)
self.dialog_model.eval()
"""
print(f'\n=== Загрузка DIALOG LLM в GPU+eval УСПЕШНО ЗАВЕРШЕНА ({calcTime(t)}c) ===\n')
# self.instruct_tokenizer =
self.tokenizer = AutoTokenizer.from_pretrained(self.ModelLocalPaths["instruct"]["id"],
cache_dir=self.thisfolder +
self.ModelLocalPaths["instruct"][
"localPath"])
# self.instruct_model =
self.model = AutoModelForSeq2SeqLM.from_pretrained(self.ModelLocalPaths["instruct"]["id"],
cache_dir=self.thisfolder +
self.ModelLocalPaths["instruct"][
"localPath"],
max_memory={0: f'{max_model_memory // 2}GB'},
torch_dtype=model_data_type,
device_map={'': 0}
# torch.float16 или bfloat16
) # .to(torch_device)
self.model.eval()
# debug todo
# self.dialog_model = self.instruct_model
# self.dialog_tokenizer = self.instruct_tokenizer
print(f'\n=== Загрузка INSTRUCT LLM в GPU+eval УСПЕШНО ЗАВЕРШЕНА ({calcTime(t)}c) ===\n')
# self.model = AutoGPTQForCausalLM.from_quantized(self.ModelID,
# cache_dir=self.thisfolder + self.ModelLocalPath,
# max_memory={0: f'{max_model_memory}GB'},
# torch_dtype=model_data_type,
# use_triton=False,
# device=torch_device
# # torch.float16 или bfloat16
# ).to(torch_device) # .cuda().to(torch.bfloat16)#
# print(f'\n=== Загрузка в CPU FT5** УСПЕШНО ЗАВЕРШЕНА ({calcTime(t)}c) ===\n')
# self.model.eval()
# self.model = self.instruct_model
# self.tokenizer = self.instruct_tokenizer
self.ModelLoaded = True
print(f'\n=== Загрузка ПОЛНАЯ LLM в GPU+eval УСПЕШНО ЗАВЕРШЕНА ({calcTime(t)}c) ===\n')
def FredT5(self, ninp, p=None, repeatDangerPart='',
returnStopReason=False): # [2.0,2.0,50,100]
if p is None:
p = {
"do_sample": True,
"top_p": 0.9,
"top_k": 50,
"temperature": 0.15,
"repetition_penalty": 1.2,
"min_length": 15,
"max_length": 150,
"no_repeat_ngram_size": 5,
"num_beams": 1,
"tokens_offset": 0,
"max_time": 12
}
self.CheckModel()
t = datetime.datetime.now()
# DEBUG TODO
# '<extra_id_0>' ДЛЯ T5
# '<pad>' ДЛЯ RUGPT?
# if p.get("model_type", "instruct") == "dialog":
# print('[DEBUG LLM] MAIN LLM MODEL SET TO DIAG** !!!')
# self.model = self.dialog_model
# self.tokenizer = self.dialog_tokenizer
# else:
# print('[DEBUG LLM] MAIN LLM MODEL SET TO **INSTRUCT !!!')
# self.model = self.instruct_model
# self.tokenizer = self.instruct_tokenizer
inp = ninp + '<extra_id_0>' # '<SC6>'+ninp+' <extra_id_0>'#'<LM>'+ninp
# print('DEBUG ИНПУТ МОДЕЛИ === \n',inp)
# print('БЕЗ СПЕЦТОКЕНОВ: ',[tokenizer.encode(inp,add_special_tokens=False)])
# print('СО: ',[tokenizer.encode(inp,add_special_tokens=True)])
input_tokens = self.tokenizer.encode(inp, add_special_tokens=False)
samplePart = []
##DEBUG
# repeatDangerPart = 'Mame4o спрашивает как дела у пользователя. | У нас всё отлично, давайте продолжим нашу беседу! Кстати, вы видели мои посты про пингвина? А какую книгу читали последнюю? И сколько уже выпили пива вместе со мной? Это было потрясающе! Я до сих пор вспоминаю это с улыбкой на лице.'
if repeatDangerPart != '':
samplePart = self.tokenizer.encode(repeatDangerPart, add_special_tokens=False)
input_cnt = len(input_tokens)
# print('DEBUG Параметры: ',str(p))
# self.TokenizerDebugPrint(input_tokens,'DEBUG ИНПУТ П0>')
input_ids = torch.tensor([input_tokens]).to(torch_device)
### МОДУЛЬ ОСТАНОВКИ ГЕНЕРАЦИИ ###
class KeywordsStoppingCriteria(StoppingCriteria):
i = 0
def __init__(self, keywords_ids: list, keywords_words_ids: list,
sample: list, controlOut: list):
self.controlOut = controlOut
self.keywords = keywords_ids
self.words = keywords_words_ids
self.sample = sample
def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor, **kwargs) -> bool:
self.i += 1
if input_ids[0][-1].item() in self.keywords:
self.controlOut.append('symbol')
print('[STOP CRITERIA] Early stopping сработал! (по символу)')
return True
if len(input_ids[0]) > 1:
# print('TENSORS',[input_ids[0][-1].item(),input_ids[0][-2].item()],'EXAMPLES',self.words[0])
if [input_ids[0][-1].item(), input_ids[0][-2].item()] in self.words:
self.controlOut.append('word')
print('[STOP CRITERIA] Early stopping сработал!')
return True
if (self.i > 5 and self.i % 5 == 0):
if findRepeatingTokens(self.sample, input_ids[0].tolist()):
self.controlOut.append('repeat')
print('[STOP CRITERIA] Early stopping REPEAT FOUND')
return True
return False
stop_symbols = ['}', '*', ']'] # ,':']
stop_words = [['\n', '*'], ['\n', 'Q'], ['Q', ':']]
stop_ids = [self.tokenizer.encode(w, add_special_tokens=False)[0] for w in stop_symbols]
stop_ids_words = []
stoppingCallback = []
for word in stop_words:
stop_ids_words.append([self.tokenizer.encode(w, add_special_tokens=False)[0] for w in word])
stop_criteria = KeywordsStoppingCriteria(stop_ids, stop_ids_words, samplePart, stoppingCallback)
### МОДУЛЬ ОСТАНОВКИ ГЕНЕРАЦИИ ###
try:
generation_config = GenerationConfig.from_pretrained(self.ModelLocalPaths["instruct"]["id"],
cache_dir=self.thisfolder +
self.ModelLocalPaths["instruct"][
"localPath"])
except BaseException as err:
print('GenConfig не нашелся потому что', err)
generation_config = GenerationConfig.from_dict({"bos_token_id": 50256, "eos_token_id": 50256,
"transformers_version": "4.27.1"}) # взято из ruGPT3.5 config
generation_config.do_sample = p.get("do_sample")
# generation_config.top_p = p["top_p"]
# generation_config.repetition_penalty = p["repetition_penalty"]
# generation_config.top_k = p["top_k"]
# generation_config.no_repeat_ngram_size = p["no_repeat_ngram_size"]
generation_config.no_repeat_ngram_size = 2
# top_p": 0.95, "top_k": 5, "repetition_penalty": 1.03,
generation_config.top_p = 0.95
generation_config.top_k = 5
generation_config.repetition_penalty = 1.03
generation_config.temperature = p.get("temperature", 0.2)
generation_config.min_length = p["min_length"]
generation_config.max_length = p["max_length"]
generation_config.max_new_tokens = p["max_length"]
generation_config.max_time = p.get("max_time", 12.0)
# generation_config.num_beams = 2#p.get("num_beams", 3) # DEBUG !!!! DEBUG TODO BEAMS
generation_config.eos_token_id = self.tokenizer.eos_token_id # self.tokenizer.encode(']',add_special_tokens=False)[0]#tokenizer.eos_token_id
generation_config.early_stopping = True
print('DEBUG GEN CONFIG = ', generation_config)
# torch.manual_seed(random.randint(0, 1000)) #ниче не дает
restart_generation = True
attempt = 0
wasEarlyStopped = False
result = ""
while restart_generation and attempt <= 3:
attempt += 1
print(f'({calcTime(t)}|{attempt}) [FT5 FT5 DEBUG!!!] DEBUG PRINT ПЕРЕД ГЕНАЦИЕЙ')
stoppingCallback.clear()
outputs = generate(self.model, input_ids, generation_config, stop_criteria)
print(f'({calcTime(t)}|{attempt}) [FT5 FT5 DEBUG!!!] DEBUG PRINT ПОСЛЕ ГЕНАЦИИ')
# https://huggingface.co/docs/transformers/v4.18.0/en/main_classes/text_generation
output = None
if (len(outputs) > 0):
self.lastTokensUsed = len(outputs[0])
output = outputs[0][1 + p["tokens_offset"]:]
wasEarlyStopped = len(stoppingCallback) > 0
# print('DEBUG TOKEN OUTS',outputs)
result = self.tokenizer.decode(output, skip_special_tokens=True)
result = CutSpaces(result.replace('<extra_id_0>', '').replace('A:', '').strip())
print(calcTime(t) + ' - время просчета, токенов [I/O] -',
'[' + str(input_cnt) + '/' + str(self.lastTokensUsed) + ']',
'earlyStop =', wasEarlyStopped, 'фрагмент генерации =', result[0:7], '\n')
if len(result) >= 4 or wasEarlyStopped:
restart_generation = False
else:
print(
'{calcTime(t)} | [FT5 WARNING] МЕНЕЕ 8 СИМВОЛОВ! ЗАПУЩЕНА ПЕРЕЗАГРУЗКА МОДЕЛИ И РЕСТАРТ ГЕНЕРАЦИИ')
# self.CheckModel(forceLoad=True)
print(f'{calcTime(t)} | FT5 >>>> CPU')
self.model.to("cpu")
torch.cuda.empty_cache()
# https://bytemeta.vip/repo/ultralytics/ultralytics/issues/4057
gc.collect()
print(f'{calcTime(t)} | FT5 >>>> EMPTED CACHE!')
print('{calcTime(t)} | FT5 >>>> CUDA')
self.model.to(torch_device)
print('{calcTime(t)} | [FT5 WARNING] ПОВТОРНАЯ ЗАГРУЗКА ЗАВЕРШЕНА')
# self.TokenizerDebugPrint(output,'DEBUG РЕЗУЛЬТ П1>')
# print('DEBUG РЕЗУЛЬТ П1>'+self.tokenizer.decode(debugOutputs))
if returnStopReason:
stoppingReason = ''
if wasEarlyStopped:
stoppingReason = stoppingCallback[0]
return {"generated": result, "stopped": stoppingReason} # БЫЛО outputs[0][1:]
else:
return result
def debug(self):
from LLMExamples import LLMExamples
examples = importlib.reload(sys.modules['LLMExamples']).examples
self.e = examples()
self.e.debug()
inp = self.e.getResult()
p = self.e.getParams()
print("Загрузка текста из подключаемого модуля")
# print('Параметры: ',str(p))
# print(inp)
return self.FredT5(inp, p)
def debug2(self):
self.CheckModel()
generation_config = GenerationConfig.from_pretrained(self.thisfolder + self.ModelLocalPath)
prompt = '<SC1>Тебя зовут Анфиса. Тебе интересно машинное обучение.' + '\nТы ответил: <extra_id_0>'
input_ids = self.tokenizer(prompt, return_tensors='pt').input_ids
out_ids = self.model.generate(input_ids=input_ids.to(torch_device), generation_config=generation_config)
t5_output = self.tokenizer.decode(out_ids[0][1:])
if '</s>' in t5_output:
t5_output = t5_output[:t5_output.find('</s>')].strip()
t5_output = t5_output.replace('<extra_id_0>', '').strip()
t5_output = t5_output.split('Собеседник')[0].strip()
print('B:> {}'.format(t5_output))
return t5_output
def chatbot(self, ninp, params, repeat_danger_context=""):
inp = ninp # self.e.getResult()
p = params # self.e.getParams()
print("Загрузка текста из подключаемого модуля")
# print('Параметры: ',str(p))
# print(inp)
reply = self.FredT5(inp, p, repeatDangerPart=repeat_danger_context, returnStopReason=True)
stopReason = reply["stopped"]
reply = reply["generated"]
# reply = "Пользователь просит меня не заебывать его. | Я думаю, что это связано с тем фактом,что он очень сильно хочет бана и боится этого больше всего на свете <команда=и****й> [эмоция=интерес]"
emotion = 'нет'
command = 'нет'
# print('R1',reply)
cmd = GetCmd(reply, tip="emo")
emotion = cmd["cmd"]
reply = cmd["cut"]
# print('R2',cmd)
cmd = GetCmd(reply, tip="cmd")
command = cmd["cmd"]
reply = cmd["cut"]
reply = CutSpaces(reply)
result = {
"stopped": stopReason,
"reply": reply,
"emotion": emotion,
"command": command,
"tokens": self.lastTokensUsed}
return result
def __init__(self, nick='obama726', syspath=''):
if syspath != '':
self.thisfolder = syspath # os.path.dirname(os.path.realpath(__file__))
print('Загрузка класса FredT5 по пути', self.thisfolder)
self.nick = nick
lm = T5()
lm.CheckModel()
loading_flag.set()
print('время запуска ' + calcTime(t))
while True:
try:
llm_input = fredCtxQueue.get()
print('[FREDT5 QUEUE] получена очередь', llm_input)
# answer = lm.chatbot(inp[0], **inp[1])
# llm_input, params, danger_context
answer = lm.chatbot(llm_input[0], llm_input[1], llm_input[2])
fredCtxQueueOutput.put(answer)
except BaseException as err:
print('[LM FRED T5 ERR] ОШИБКА ПРОЦЕССА ВО FRED T5: ', err)
print('[LM FRED T5 ERR] ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
print("\n[LM FRED T5 ERR] === КОНЕЦ ОШИБКИ ====")
time.sleep(1)
if __name__ == "__main__": # DEBUG NOT WORK
print('ЗАПУСК ЧО')
manager = multiprocessing.Manager()
t = datetime.datetime.now()
fredCtxQueue = manager.Queue()
fredCtxQueueOutput = manager.Queue()
loading_flag = manager.Event()
repeating_dict = manager.dict()
DOCKER_SENDER_ENABLED = False
if DOCKER_SENDER_ENABLED:
from HyperAI_Docker import DockerSender
# ЧЕКНУТЬ АУТПУТ ПРОЦЕССА strace -ewrite -p $PID
docker_sender = DockerSender()
else:
LargeFREDProc = multiprocessing.Process(
target=FRED_PROCESS,
args=(loading_flag, fredCtxQueue, fredCtxQueueOutput,
repeating_dict,)) # Thread(target = a, kwargs={'c':True}).start()
LargeFREDProc.start()
# FRED_PROCESS(fredCtx)
print('ЗАПУСК ЧО2')
from LLMExamples import LLMExamples, get_llm_formed_inputs
def FredT5ChatbotQueue(ninp, context, paramsOverride, environment, lmUsername):
if environment.get("own_prompt", False):
llm_input, params, danger_context = ninp, \
{"do_sample": True,
"top_p": 0.98, "temperature": 0.65, "repetition_penalty": 1.3, "min_length": 10,
"max_length": 150, "tokens_offset": 0, "top_k": 50,
"no_repeat_ngram_size": 5, "num_beams": 3, "max_time": 12, }, \
"ну че как дела"
else:
llm_input, params, danger_context = get_llm_formed_inputs(inp=ninp, username=lmUsername,
params_override=paramsOverride,
environment=environment, dialog_context=context,
repeating_dict=repeating_dict)
fredCtxQueue.put((llm_input, params, danger_context))
out = fredCtxQueueOutput.get()
return out
# return docker_sender.chatbot(llm_input, params, danger_context)
loading_flag.wait()
# print(e.getResult()+'mda')
print('время запуска ТЕСТА ' + calcTime(t))
while True:
inp = input(
"1-yt,2-mine,3-БЕЗ ПРОМПТА,!-ник,4-DIALOG mc,5-INSTRUCT mc,6-welcome,без-о системе\n:>") # 'чобабке>>'
if inp != "" or inp != "ext":
if inp[0] == "!":
print('ANS',
FredT5ChatbotQueue("Как дела зшщз", "", None, {"env": "youtube", "diags_count": 0}, inp[1:]))
elif inp[0] == "1":
print('ANS',
FredT5ChatbotQueue(inp[1:], "", None, {"env": "youtube", "sentence_type": "dialog"}, "lexeCho"))
elif inp[0] == "2":
print('ANS',
FredT5ChatbotQueue(inp[1:], "", None, {"env": "minecraft", "sentence_type": "dialog"}, "lexeCho"))
elif inp[0] == "3":
print('ANS',
FredT5ChatbotQueue(inp[1:], "", None,
{"env": "minecraft", "own_prompt": True, "sentence_type": "dialog"},
"lexeCho"))
elif inp[0] == "4":
print('ANS',
FredT5ChatbotQueue(inp[1:], "", {"model_type": "dialog"},
{"env": "minecraft", "sentence_type": "dialog"}, "lexeCho"))
elif inp[0] == "5":
print('ANS',
FredT5ChatbotQueue(inp[1:], "", {"model_type": "instruct"},
{"env": "minecraft", "sentence_type": "dialog"}, "lexeCho"))
elif inp[0] == "6":
print('ANS',
FredT5ChatbotQueue(inp[1:], "", {"model_type": "instruct"},
{"env": "broadcast", "broadcast_type": "stream_ad"}, "lexeCho"))
elif inp[0] == "7":
print('ANS',
FredT5ChatbotQueue(inp[1:], "", {"model_type": "instruct"},
{"env": "broadcast", "broadcast_type": "status_report"}, "lexeCho"))
else:
print('ANS',
FredT5ChatbotQueue(inp, "", None, {"env": "youtube", "sentence_type": "about_system"}, "lexeCho"))
Ну что, кодеры мои, не накодились мы ещё? А ведь мы сделали только генератор сообщений, нам к этому ещё нужно подвязать базу данных, интерфейс управления с ней, связать процесс майна и нашу генеративку и я уже не говорю о ТТСке и прочих вещах!
База данных и интерфейс работы с ней
Исходя из проекта нашей диалоговой системы, в БД нам понадобятся следующие таблицы: пользователи и диалоги. Дополнительно добавим возможность каждому пользователю иметь несколько ников, ну и для прикола чтобы БД выглядела повнушительнее зачем-то сделаем таблицу с одним рядом для «настроения» системы. Составим схему связей этих таблиц:
[саркастические хлопки] Да-да, знаю, классно я составил схему — сначала уже сделал БД, 100 раз её перелопатил, а потом уже и итоговую схему выкатил))
Итак, у нас есть схема БД, воспроизвести её можно как по визуальному представлению, так и через DDL. Не знаю, кому это надо, но вот, держите, DDL sql код для создания каждой таблицы из нашей БД в sqlite:
Кодопомойка CreateTables.sql (ddl-sql для sqlite)
CREATE TABLE users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT
UNIQUE
NOT NULL,
name TEXT,
rank REAL,
firstreg TEXT NOT NULL,
last_interact TEXT NOT NULL,
last_answered TEXT,
diags_count INTEGER DEFAULT (0)
NOT NULL,
last_question_date TEXT,
last_question TEXT,
real_name TEXT
);
CREATE TABLE users_nicknames (
nick_id INTEGER PRIMARY KEY AUTOINCREMENT
NOT NULL,
user_id NUMERIC REFERENCES users (user_id) ON DELETE CASCADE
NOT NULL,
nick TEXT NOT NULL,
env TEXT,
server TEXT,
other_info TEXT,
added_date TEXT NOT NULL,
nick_analyze TEXT,
UNIQUE (
nick,
env
)
);
CREATE TABLE users_dialogs (
diag_id INTEGER PRIMARY KEY AUTOINCREMENT
NOT NULL,
user_id INTEGER REFERENCES users (user_id) ON DELETE CASCADE
NOT NULL,
diag_nick TEXT,
content TEXT NOT NULL,
role TEXT NOT NULL,
date TEXT NOT NULL,
command TEXT,
emotion TEXT,
env TEXT,
server TEXT,
other_info TEXT,
bind_to_diag_id INTEGER REFERENCES users_dialogs (diag_id) ON DELETE CASCADE,
bind_to_nick_id INTEGER REFERENCES users_nicknames (nick_id) ON DELETE SET NULL,
filter_allowed INTEGER,
filter_topics TEXT
);
CREATE TABLE mind_variables (
mind_id INTEGER PRIMARY KEY AUTOINCREMENT
UNIQUE
NOT NULL,
mind_name TEXT,
mood REAL NOT NULL
DEFAULT (0.0),
last_changed TEXT NOT NULL
);
Дальше пишем интерфейс взаимодействия. И даже на таком самом простом, казалось бы, этапе нас могут поджидать сложности. Дело в том, что у нас — мультипроцессорный скрипт! А sqlite не очень любит многопоток и, соответственно, многопроцесс. В общем просто отрубаем все защитные механизмы к фигам и делаем простой интерфейс со всеми нужными нам функциями вроде вытаскивания нескольких последних диалогов пользователя, определение его на наличие в базе и т.д.
Кодопомойка Database.py
import random
import sqlite3 as sl
sl.threadsafety = 3
from datetime import datetime
from typing import Union
import traceback
import time
def eztime():
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def tm(x):
return datetime.strptime(x, '%Y-%m-%d %H:%M:%S')
def clamp(n, smallest, largest):
return max(smallest, min(n, largest))
class HyperAIDatabase:
def __init__(self):
safety_mode = sl.threadsafety # ДОЛЖНО УКАЗЫВАТЬСЯ ПЕРЕД ИМПОРТОМ!
# print(f'[DB PRE-INIT] default threadsafety {safety_mode}, attempting to change to 3...')
# sl.threadsafety = 3
# https://docs.python.org/3/library/sl.html
# https://ricardoanderegg.com/posts/python-sqlite-thread-safety/
if safety_mode == 3:
self.db_connection = sl.connect('HyperAI_DATABASE.db', check_same_thread=False)
print("[DB INIT] SUCCESSFULLY CONNECTED TO DATABASE! sl.threadsafety = 3")
else:
print(
f"[DB CANT BE USED!!! RAISING EXCEPTION!!! Почему? Да потому что sl.threadsafety = {str(safety_mode)}")
raise Exception('**** БАЗА ДАННЫХ В Ж')
# self.cursor = self.db_connection.cursor()
def save_db_changes(self) -> bool:
fail = True
try_num = 0
while fail and try_num < 10:
try_num += 1
try:
self.db_connection.commit()
fail = False
return True
except BaseException as err:
print(f'[DB SAVE ERR] ОШИБКА N={str(try_num)} ПРИ СОХРАНЕНИИ БАЗЫ ДАННЫХ! ', err, )
print('ТЕКСТ СОХРАНЕНИЯ ОШИБКИ', traceback.format_exc())
print("\n=== КОНЕЦ ОШИБКИ ====")
time.sleep(0.1)
return False
def exec(self, cursor, sql):
cursor.execute(sql)
self.save_db_changes()
# cursor.close()
def get_cursor_result(self, cursor: sl.Cursor) -> any:
row = cursor.fetchone()
if row is not None:
if len(row) > 0:
result = row[0]
if result is not None:
return result
return None
def get_cursor_results(self, cursor: sl.Cursor) -> any:
rows = cursor.fetchall()
if rows is not None:
if len(rows) > 0:
return rows
return None
def get_user_id(self, nick: str, cursor=None) -> int:
##SELECT ID FROM table_name WHERE City LIKE String
if cursor is None:
cursor = self.db_connection.cursor()
sql = f"""SELECT user_id FROM users_nicknames WHERE nick = ?"""
cursor.execute(sql, (nick,))
return self.get_cursor_result(cursor)
def get_db_field(self, field_id: int, field_name: str, table_name: str = "users", id_name: str = "user_id",
many: bool = False, cursor: sl.Cursor = None):
if cursor is None:
cursor = self.db_connection.cursor()
result = None
try:
cursor.execute(f"SELECT {field_name} FROM {table_name} WHERE {id_name} = ?", (field_id,))
if many:
result = self.get_cursor_results(cursor)
if result is not None and len(result) <= 0:
result = None
else:
result = self.get_cursor_result(cursor)
except BaseException as err:
print("[DB GETTER FIELD ERR] наверное такого поля нету, ерр:", err)
return result
def set_db_field(self, field_id: int, field_name: str, field_new_value: any, table_name: str = "users",
id_name: str = "user_id", cursor: sl.Cursor = None) -> bool:
if cursor is None:
cursor = self.db_connection.cursor()
success = False
try:
cursor.execute(f"UPDATE {table_name} SET {field_name} = ? WHERE {id_name} = ?",
(field_new_value, field_id,))
if cursor.rowcount >= 1:
if self.save_db_changes():
success = True
# cursor.execute(f"SELECT {field_name} FROM {table_name} WHERE {id_name} = ?", (field_id,))
except BaseException as err:
print("[DB SETTER FIELD ERR] наверное такого поля нету, ерр:", err)
return success
def set_mood(self, new_mood: float, mind_id: int = 1) -> bool:
cursor = self.db_connection.cursor()
if self.set_db_field(field_id=int(mind_id), field_name="mood", field_new_value=new_mood,
table_name="mind_variables", id_name="mind_id", cursor=cursor):
return self.set_db_field(field_id=int(mind_id), field_name="last_changed",
field_new_value=eztime(), table_name="mind_variables", id_name="mind_id",
cursor=cursor)
return False
def get_mood(self, mind_id: int = 1) -> float:
return self.get_db_field(field_id=int(mind_id), field_name="mood",table_name="mind_variables",id_name="mind_id")
def get_user_rank(self, user_id: int):
return self.get_db_field(field_id=int(user_id), field_name="rank")
def get_user_last_interact_time(self, user_id: int, last_answered=False):
if last_answered:
return self.get_db_field(field_id=int(user_id), field_name="last_answered")
else:
return self.get_db_field(field_id=int(user_id), field_name="last_interact")
def get_user_last_question_date(self, user_id: int):
return self.get_db_field(field_id=int(user_id), field_name="last_question_date")
def set_user_last_question(self, user_id: int, question: str) -> bool:
cursor = self.db_connection.cursor()
return self.set_db_field(field_id=int(user_id), field_name="last_question_date",
field_new_value=eztime(), cursor=cursor) and self.set_db_field(field_id=int(user_id),
field_name="last_question",
field_new_value=question, cursor=cursor)
def set_user_last_interact_time(self, user_id: int, new_value: str, last_answered=False):
if last_answered:
return self.set_db_field(field_id=int(user_id), field_name="last_answered", field_new_value=new_value)
else:
return self.set_db_field(field_id=int(user_id), field_name="last_interact", field_new_value=new_value)
def set_user_rank(self, user_id: int, new_rank: float):
return self.set_db_field(field_id=int(user_id), field_name="rank", field_new_value=clamp(new_rank, 0.0, 10.0))
def add_to_user_rank(self, user_id: int, amount: float):
rank = self.get_user_rank(user_id)
if rank is not None:
result_rank = clamp((amount + rank), 0.0, 10.0)
if self.set_user_rank(user_id, new_rank=result_rank):
return result_rank
else:
return -1
else:
return -1
def check_user_id_exists(self, user_id: int, cursor: sl.Cursor = None):
if cursor is None:
cursor = self.db_connection.cursor()
cursor.execute("SELECT COUNT(user_id) FROM users WHERE user_id = ?", (user_id,))
result = self.get_cursor_result(cursor)
if result:
return True
else:
return False
def get_or_create_user(self, data: dict, return_nick_id_too: bool = False) -> Union[int, dict, None]:
nick = data["user"]
if nick.strip():
user_id = self.get_user_id(nick)
if user_id is None:
user_id = self.add_new_user(data)
if not return_nick_id_too:
return user_id
else:
return {"user_id": user_id, "nick_id": self.get_user_nick_id(nick, data)}
return None
def get_user_nick_id(self, nick: str, data: dict = None, cursor: sl.Cursor = None) -> int:
if cursor is None:
cursor = self.db_connection.cursor()
if data is not None:
env = data.get("env", None)
server = data.get("server", None)
if server and env:
cursor.execute(
"""SELECT nick_id FROM users_nicknames WHERE nick = (?) AND env = (?) AND server = (?)""",
(nick, env, server))
elif env:
cursor.execute("""SELECT nick_id FROM users_nicknames WHERE nick = (?) AND env = (?)""",
(nick, env))
else:
cursor.execute("""SELECT nick_id FROM users_nicknames WHERE nick = (?)""", (nick,))
return self.get_cursor_result(cursor)
def set_analyze_for_nick(self, nick_id: int, nick_analyze:str) -> bool:
return self.set_db_field(field_id=int(nick_id),field_name="nick_analyze",field_new_value=nick_analyze,table_name="users_nicknames",id_name="nick_id")
def get_nick_analyze(self, nick_id: int, return_bool: bool):
if return_bool:
return True if self.get_db_field(field_id=int(nick_id),field_name="nick_analyze",table_name="users_nicknames",id_name="nick_id") else False
else:
return self.get_db_field(field_id=int(nick_id), field_name="nick_analyze", table_name="users_nicknames", id_name="nick_id")
def add_nick_to_user(self, user_id: int, data: dict) -> int:
nick = data["user"]
env = data["env"]
server = data.get("server", None)
date = eztime()
cursor = self.db_connection.cursor()
sql = f"""
INSERT OR IGNORE INTO users_nicknames (user_id,nick,env,server,other_info,added_date)
VALUES (?,?,?,?,?,?)
RETURNING nick_id
"""
cursor.execute(sql, (user_id, nick, env, server, None, date,))
inserted_nick_id = self.get_cursor_result(cursor)
if inserted_nick_id is not None:
self.save_db_changes()
print('[DB ADD NEW NICK TO USER ID(' + str(user_id) + ') INSERTED ' + nick + '. Nick in table ID = (' + str(
inserted_nick_id) + ')')
return inserted_nick_id
def add_new_user(self, data: dict = None) -> Union[int, None]:
if data == None:
print("[DB EXCEPTION] **** НАДО ПЕРЕДАТЬ ДАННЫЕ В БАЗУ а DATA пуст!")
return None
cursor = self.db_connection.cursor()
rank = random.choice([3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 4.0])
date = eztime()
name = data["user"]
data["env"] = data.get("env", "minecraft")
sql = f"""
INSERT INTO users (user_id,name,rank,firstreg,last_interact,diags_count)
VALUES (?,?,?,?,?,?)
RETURNING user_id
f"""
cursor.execute(sql, (None, name, rank, date, date, 0,))
inserted_id = self.get_cursor_result(cursor)
if inserted_id is not None:
self.save_db_changes()
print('[DB ADD NEW USER] INSERTED ' + name + ' AT ID ' + str(inserted_id) + ' DEBUG TYPE ' + str(
type(inserted_id)))
self.add_nick_to_user(user_id=inserted_id, data=data) # +ник к юзеру
if data.get("env", "") == "discord": # +дискорд ид к юзеру
discord_user_id = data.get("discord_id", None)
if discord_user_id:
discord_data = dict(data)
discord_data["env"] = "discord_id"
discord_data["user"] = discord_user_id
self.add_nick_to_user(user_id=inserted_id, data=discord_data)
print(f'[DB ADD DISCORD NICK] Добавлен для ника {str(name)} discord_id в дискорде')
elif data.get("env", "") == "youtube": # +ютуб ид к юзеру
youtube_user_id = data.get("youtube_user_channel_id", None)
if youtube_user_id:
youtube_data = dict(data)
youtube_data["env"] = "youtube_user_channel_id"
youtube_data["user"] = youtube_user_id
self.add_nick_to_user(user_id=inserted_id, data=youtube_data)
print('[DB ADD YT] Добавлен ID в YT')
elif data.get("env", "") == "trovo": # +trovo ид к юзеру
trovo_user_id = data.get("trovo_user_channel_id", None)
if trovo_user_id:
trovo_data = dict(data)
trovo_data["env"] = "trovo_user_channel_id"
trovo_data["user"] = str(trovo_user_id)
self.add_nick_to_user(user_id=inserted_id, data=trovo_data)
print('[DB ADD TROVO] Добавлен ID TROVO')
return inserted_id
return None
def add_diags(self, user_id: int, diag_to_add: list[dict], data: dict = None) -> list[int]:
# log = {"user":user, "role": role, "content": content, "date":eztime(), "emotion":emotion, "command":command}
cursor = self.db_connection.cursor()
diag_ids = []
date = eztime()
if data is None:
env = None
server = None
other_info = None
bind_to_nick_id = None
diag_nick = None
else:
def get_other_info_from_env(d):
result = ""
sentence_type = d.get("sentence_type", "")
if sentence_type != "":
result += "sentence_type=" + sentence_type
return result if result else None
diag_nick = data.get("user", None)
env = data.get("env", None)
server = data.get("server", None)
other_info = get_other_info_from_env(data) # TODO
bind_to_nick_id = None # data.get("user_id", None)
if bind_to_nick_id == None and diag_nick:
if diag_nick.strip() != "":
bind_to_nick_id = self.get_user_nick_id(nick=diag_nick, data=data, cursor=cursor)
last_user_diag_id = None
last_interact_time = None # get last interact
for i, record in enumerate(diag_to_add):
filter_allowed = record.get("filter_allowed", None)
if filter_allowed is not None:
filter_allowed = int(filter_allowed)
filter_topics = record.get("filter_topics", None)
if not filter_topics:
filter_topics = None
diag_nick = record.get("user", diag_nick)
role = record["role"]
bind_to_diag_id = None
date_rec = record.get("date", None)
if role == "assistant" and last_user_diag_id is not None:
if date_rec is not None:
last_interact_time = date_rec
bind_to_diag_id = last_user_diag_id
if date_rec is None:
date_rec = date
sql = f"""
INSERT INTO users_dialogs (user_id,diag_nick,content,role,date,command,emotion,env,server,other_info,bind_to_diag_id,bind_to_nick_id,filter_allowed,filter_topics)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING diag_id
f"""
cursor.execute(sql, (user_id, diag_nick, record["content"], role, date_rec,
# (user_id,diag_nick,content, role, date,
record.get("command", None), record.get("emotion", None),
# command, emotion,
env, server, other_info, bind_to_diag_id, bind_to_nick_id,
filter_allowed, filter_topics,
))
# env,server,other_info,bind_to_diag_id,bind_to_nick_id)
inserted_diag_id = self.get_cursor_result(cursor)
if inserted_diag_id is not None:
diag_ids.append(inserted_diag_id)
if role == "user":
last_user_diag_id = inserted_diag_id
# INCREMENT DIAGS COUNT VALUE
if bind_to_diag_id is not None:
cursor.execute('UPDATE users SET diags_count = diags_count + 1 WHERE user_id = ?', (user_id,))
if len(diag_ids) > 0:
if last_interact_time:
cursor.execute('UPDATE users SET last_answered = ? WHERE user_id = ?', (last_interact_time, user_id,))
if self.save_db_changes():
return diag_ids
return []
def get_user_diags(self, user_id: Union[int, None], count: int = 1):
# SELECT * FROM l LIMIT 100
cursor = self.db_connection.cursor()
user_id_compar = ""
if user_id is not None:
user_id_compar = f"user_id = {str(user_id)} AND "
sql = f"""
SELECT * FROM (
SELECT diag_id,diag_nick,content,role,date,command,emotion,env FROM users_dialogs WHERE diag_id IN
(SELECT bind_to_diag_id FROM users_dialogs WHERE {user_id_compar}bind_to_diag_id IS NOT NULL AND role = 'assistant')
UNION
SELECT diag_id,diag_nick,content,role,date,command,emotion,env FROM users_dialogs WHERE {user_id_compar}bind_to_diag_id IS NOT NULL AND role = 'assistant'
ORDER BY diag_id DESC
LIMIT {str(count * 2)}
)
ORDER BY diag_id ASC
"""
# cursor.execute(sql,(user_id,user_id,count*2,))
cursor.execute(sql)
result_diags = self.get_cursor_results(cursor)
diags = []
if result_diags is not None:
for d in result_diags:
# 0 diag_id,1 diag_nick,2 content,3 role,4 date,5 command,6 emotion,7 env
# {"user":d[1],"role":d[3],"content":d[2],"date":d[4],"command":d[5],"emotion":d[6],"env":d[7]}
diag_dict = {}
field_ids = {"user": 1, "role": 3, "content": 2, "date": 4, "command": 5, "emotion": 6, "env": 7}
for field, value in field_ids.items():
if d[value]:
diag_dict[field] = d[value]
diags.append(diag_dict)
return diags
def get_user_diag_count(self, user_id: int, real=True, cursor: sl.Cursor = None):
if cursor is None:
cursor = self.db_connection.cursor()
if real:
if self.check_user_id_exists(user_id):
cursor.execute("SELECT COUNT(diag_id) FROM users_dialogs WHERE user_id = ? AND role = 'assistant'",
(user_id,))
else:
return None
else:
cursor.execute("SELECT diags_count FROM users WHERE user_id = ?", (user_id,))
return self.get_cursor_result(cursor)
def get_last_any_diag_time(self, cursor: sl.Cursor = None):
if cursor is None:
cursor = self.db_connection.cursor()
cursor.execute("""SELECT * FROM (
SELECT date,diag_id FROM users_dialogs WHERE bind_to_diag_id IS NOT NULL AND role = 'assistant'
ORDER BY diag_id DESC
LIMIT 1
)""")
return self.get_cursor_result(cursor)
def get_relevant_diag(self, user_id: Union[int, None] = None, count: int = 1, exact_user_timeout: float = 160.0,
any_user_timeout: float = 250.0) -> list[dict]:
now = datetime.now()
if user_id is not None:
last_answered = self.get_db_field(field_id=user_id, field_name="last_answered")
if last_answered:
try:
if (now - tm(last_answered)).total_seconds() > exact_user_timeout:
user_id = None
except BaseException as err:
print(f'[DB Get Time EXACT relevant user_id={str(user_id)} diag ERR] err =', err)
user_id = None
else:
user_id = None
diags = []
if user_id is None:
last_answered = self.get_last_any_diag_time()
if last_answered:
try:
if (now - tm(last_answered)).total_seconds() <= any_user_timeout:
print('[DB get ANY RELEVANT DIAG] time succed, searching for ANY diag..')
diags = self.get_user_diags(user_id=None, count=count)
except BaseException as err:
print('[DB Get Time ANY relevant diag ERR] err =', err)
else:
print(f'[DB get EXACT user_id={str(user_id)} RELEVANT] time succed, searching for EXACT diag..')
diags = self.get_user_diags(user_id=user_id, count=count)
return diags
def connection_close(self):
self.db_connection.close()
if __name__ == "__main__": #debugging
db = HyperAIDatabase()
# db.add_nick_to_user(4, {"user": "LexaLepexa", "env": "youtube"})
# debugChatEntry = {"user": "LexaLepaxa2324", "env": "minecraft", "server": "mc.musteryworld.me", "date": eztime()}
# id = db.get_or_create_user(debugChatEntry)
## id = db.get_user_id("NetTyan")
# print("id = " + str(id), type(id))
# curs = db.db_connection.cursor()
# curs.execute("SELECT * FROM users_nicknames WHERE nick LIKE ?", ("LexaLepexa",))
# print('lol', db.get_cursor_results(curs), 'lol')
debug_dialog = [
{"user": "LexaLepexa", "role": "user", "content": "2Привет! Как дела?", "date": "2023-06-30 20:22:59",
"emotion": "", "command": ""},
{"user": "default", "role": "assistant", "content": "3Пока! Ты скучный!", "date": "2023-06-30 20:23:00",
"emotion": "агрессия", "command": "бан"},
{"user": "LexaLepexa", "role": "user", "content": "4ЭЭэээ", "date": "2023-06-30 20:24:59",
"emotion": "", "command": ""},
{"user": "default", "role": "assistant", "content": "5Не эээкай нищщщ", "date": "2023-06-30 20:25:00",
"emotion": "агрессия", "command": "бан"},
]
# db.add_diags(user_id=db.get_user_id("LexaLepexa"),diag_to_add=debug_dialog)
# print(db.get_user_nick_id("LexaLepexa",{"env":"youtube"}))
# print(db.get_user_diag_count(user_id=db.get_user_id("Net Tyan"),real=True))#db.get_user_diags(user_id=None, count=1))
# print(db.set_db_field(field_id=4,field_new_value=None,field_name="other_info", table_name="users_nicknames", id_name="user_id"))
# print(db.get_last_any_diag_time())
# print(db.get_relevant_diag(user_id=db.get_user_id("LexaLepexa"),count=1,exact_user_timeout=1000000.0,any_user_timeout=10000000.0))
#print(db.add_to_user_rank(user_id=7, amount=-3.2))
print(db.set_mood(-5))
print(db.get_mood())
db.connection_close()
# print('Debug mode actived')
# db = sl.connect('HyperAI_DATABASE.db')
# cursor = db.cursor()
# db_add_new_user()
##db_init(db, cursor)
# db.close()
И — вуаля! Теперь наша дорогая тян может общаться! Хотел бы я сказать. Но нет.
Перед нами всё ещё остаётся та самая тернистая преграда — надо подвзять систему управления ботом из игры к генеративке.
Связываем диалоговую систему, базу данных и игру
Перед началом скажу, что ядро диалоговой мы будем ставить на Docker. Для него же пишем интерфейс с использованием SyncManager.
Образ Docker я также далее (в статье) буду использовать для топового фреймворка распознавания речи Nvidia NEMO, поэтому во фрагментах кода ниже вы можете заметить использование неких NemoSpeechTranscriber — это и есть классы для распознавания речи, и их код будет дальше.
Кодопомойка docker_reciever.py (Docker-часть)
from multiprocessing import Process
import time
from datetime import datetime
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
def calcTime(time):
return bcolors.OKGREEN + str((datetime.now() - time).total_seconds()) + bcolors.ENDC
import multiprocessing
from multiprocessing.managers import SyncManager
class MyManager(SyncManager):
pass
# control dict
syncdict = {}
# llm
llm_inputQueue = multiprocessing.Queue()
llm_outputQueue = multiprocessing.Queue()
llm_loading_flag = multiprocessing.Event()
# sttCtx = multiprocessing.N
def get_llm_loading_flag():
return llm_loading_flag
def get_llm_input_q():
return llm_inputQueue
def get_llm_output_q():
return llm_outputQueue
# tts
tts_inputQueue = multiprocessing.Queue()
tts_outputQueue = multiprocessing.Queue()
def get_tts_input_q():
return tts_inputQueue
def get_tts_output_q():
return tts_outputQueue
def get_dict():
return syncdict
def nemo_tts_process(manager):
debug_iter = 0
from STT.stream_stt_inf import NemoSpeechTranscriber
transcriber = NemoSpeechTranscriber()
transcriber.check_initialization()
while True:
inp = manager.tts_input_q().get()
print("INPUT GOT!", debug_iter) # ,inp,debug_iter)
debug_iter += 1
# inp = inp+" "+str(debug_iter)
output = transcriber.audio_transcribe(audio_samples=inp["bytes_io"], audio_settings=inp["audio_settings"])
print("PROCESSING READY, SENDING OUT! ", inp)
manager.tts_output_q().put(output)
# print('waiting for action, syncdict %s' % (syncdict))
# time.sleep(5)
def llm_process(manager):
from LLM.FredT5 import FRED_PROCESS
FRED_PROCESS(manager.llm_loading_flag(), manager.llm_input_q(), manager.llm_output_q())
if __name__ == "__main__":
MyManager.register("syncdict", get_dict)
MyManager.register("tts_input_q", get_tts_input_q)
MyManager.register("tts_output_q", get_tts_output_q)
MyManager.register("llm_input_q", get_llm_input_q)
MyManager.register("llm_output_q", get_llm_output_q)
MyManager.register("llm_loading_flag",get_llm_loading_flag)
manager = MyManager(("0.0.0.0", 6006), authkey=b"ktejrlktjrewlku423gdfgn")
print("Started listener manager 0.0.0.0 : 6006")
manager.start()
STT_Process = Process(
target=nemo_tts_process,
args=(manager,)) # Thread(target = a, kwargs={'c':True}).start()
STT_Process.start()
LLM_Process = Process(
target=llm_process,
args=(manager,))
LLM_Process.start()
print('Wait for loading LLM... (LLM!)')
manager.llm_loading_flag().wait()
print('WAITING COMPLETED! LLM!')
while True:
time.sleep(1)
if manager.syncdict().get("stop", False) == True:
print('TERMINATING DOCKER RECIEVER')
break
# ii = input("чонадо")
# if ii=="":
# print('TERMINATING DOCKER RECIEVER')
# break
# transcriber.audio_transcribe(audio_file="test2vloger.wav")
# raw_input("Press any key to kill server".center(50, "-"))
STT_Process.terminate()
STT_Process.join()
LLM_Process.terminate()
LLM_Process.join()
manager.shutdown()
Кодопомойка docker_sender.py (Главный скрипт)
import os
# pip install docker
import subprocess, shlex
from multiprocessing.managers import SyncManager
import time, datetime
class MyManager(SyncManager):
pass
MyManager.register("syncdict")
MyManager.register("tts_input_q")
MyManager.register("tts_output_q")
MyManager.register("llm_input_q")
MyManager.register("llm_output_q")
MyManager.register("llm_loading_flag")
def get_or_run_docker_container():
import docker
client = docker.from_env()
container_name = "nemo_stt"
container_image = "nvcr.io/nvidia/nemo:23.04"
container = None
for l in client.containers.list(all=True):
if l.name == container_name:
container = l
if container is not None:
if container.status == "running":
print('[DOCKER] container', container_name, "running!")
else: # if container.status == "exited":
container.start()
print('[DOCKER] container starting..')
time.sleep(5)
else:
thisfolder = os.path.dirname(os.path.realpath(__file__)).replace('\\', '/')
docker_image_work_dir = "/workspace/nemo/"
print('[DOCKER] container NON EXIST! Create and start.....')
# СЕЙЧАС ОТКЛЮЧЕНЫ GPU! ЧТОБЫ ВКЛЮЧИТЬ ДОБАВИТЬ ТЕГ --gpus all
mountControlDir = f"-v {thisfolder}/zHyperAI_Docker/docker_reciever.py:{docker_image_work_dir}docker_reciever.py" +\
f" -v {thisfolder}/zHyperAI_Docker/other:{docker_image_work_dir}other"
mountSTTDir = f"-v {thisfolder}/zHyperAI_Models/STT/docker_to_send:{docker_image_work_dir}STT/"
mountTTSDir = f"-v {thisfolder}/zHyperAI_Models/TTS:{docker_image_work_dir}TTS/"
mountLLMDir = f"-v {thisfolder}/zHyperAI_Models/LLM:{docker_image_work_dir}LLM/"
mountFiltersDir = f"-v {thisfolder}/zHyperAI_Models/Filters:{docker_image_work_dir}Filters/"
create_command = f"docker run {mountSTTDir} {mountLLMDir} {mountFiltersDir} {mountTTSDir} {mountControlDir} --gpus all --shm-size=8g -p 8888:8888 -p 6006:6006 -p 6523:6523 -i --ulimit memlock=-1 --ulimit stack=67108864 -d=true --name {container_name} {container_image} /bin/sh"
# должно быть -it, но из-за кастрации..
# https://stackoverflow.com/questions/43099116/error-the-input-device-is-not-a-tty
print('[DOCKER SENDER] EXECUTION',create_command)
subprocess.run(shlex.split(create_command), shell=True)
# print('Create command output =',subprocess.getoutput(create_command))
# p = subprocess.Popen(shlex.split(create_command), shell=True)
# os.system()
# time.sleep(5)
# print('trying echo')
# p.communicate("echo 1")
time.sleep(2.5)
print('Container CREATED! (probably) wait 3 sec')
container = client.containers.get(container_name)
time.sleep(3)
# client.containers.run(container_name,detach=True,
# ports={'8888/tcp': 8888,
# '6006/tcp': 6006,
# '6523/tcp': 6523,},
# volumes=[f'{thisfolder}/docker_to_send:/workspace/nemo/']
# )
# os.system("docker ")
return container, container_name
def check_reciever_process_started(container_name):
linux_cmd = """sh /other/reciever_proc_check.sh docker_reciever.py"""
windows_cmd = f"docker exec {container_name}"
# p = subprocess.Popen([windows_cmd+" "+linux_cmd], stderr=subprocess.PIPE)
# result = p.stdout.read()
result = subprocess.getoutput(windows_cmd + " " + linux_cmd)
print('[DOCKER DEBUG RECIEVER CHECK] RESULT SUBPROCESS =', result, '!')
if result.find("Running") != -1:
return True
elif result.find("Stopped") != -1:
return False
else:
print('RESULT SUBPROCESS =', result, '!')
return False
# https://stackoverflow.com/questions/18739239/python-how-to-get-stdout-after-running-os-system
#print('Starting docker sender...')
# client.containers.get(container_name)
def check_docker_app():
# docker run -v F:/Onix/Downloads/minebot/1HyperAI/zHyperAI_Models/STT/docker_to_send:/workspace/nemo/ --shm-size=8g -p 8888:8888 -p 6006:6006 -p 6523:6523 --gpus all -it --ulimit memlock=-1 --ulimit stack=67108864 --name nemo_stt nvcr.io/nvidia/nemo:23.04 /bin/sh
container, container_name = get_or_run_docker_container()
if not check_reciever_process_started(container_name):
time.sleep(0.5)
container.exec_run("python docker_reciever.py", detach=True)
print('STARTED DOCKER RECIEVER! Waiting 15 secs for it initialize')
time.sleep(15)
def file_to_bytes_io(filename):
import io
fileOpen = open(filename, 'rb+')
filee = fileOpen.read()
samples_file = io.BytesIO(filee)
fileOpen.close()
return samples_file
def kill_docker_reciever(): # если изменен конечный файл нада перезапуск
import docker
client = docker.from_env()
container = client.containers.get("nemo_stt")
time.sleep(0.2)
print("закрываем процесс docker_reciever.py")
container.exec_run("pkill -f docker_reciever.py", privileged=True, detach=True, stream=True)
time.sleep(0.2)
exit()
class DockerSender():
def __init__(self):
self.manager = None
self.initialized = False
self.check_connection()
def check_connection(self, force=False):
if not self.initialized or force:
try:
print('[DOCKER SENDER INIT] Starting SENDER manager...')
self.manager = MyManager(('localhost', 6006), authkey=b'ktejrlktjrewlku423gdfgn')
self.manager.connect()
self.initialized = True
print('[DOCKER SENDER INIT] SUCCESSFULL CONNECTED!')
except BaseException as err:
print('[DOCKER STT SENDER] Ошибка при подключении:',err,'запуск чекера')
check_docker_app()
def stop_docker_reciever(self):
if self.initialized:
self.manager.syncdict()["stop"] = True
def llm_loading_flag(self):
if self.initialized:
return self.manager.llm_loading_flag()
else:
self.check_connection()
return self.manager.llm_loading_flag()
def chatbot(self, llm_input, params, danger_context):#ninp,context,paramsOverride,environment,lmUsername):
self.check_connection()
try:
#keywords = {"context": context, "paramsOverride": paramsOverride, "environment": environment,
# "lmUsername": lmUsername}
#self.manager.llm_input_q().put((ninp,keywords))
self.manager.llm_input_q().put((llm_input, params, danger_context))
out = self.manager.llm_output_q().get()
return out
except BaseException as err:
print('[DOCKER TTS SEND] Ошибка',err,' ПЕРЕПОДКЛЮЧЕНИЕ!')
self.check_connection(force=True)
def transcribe(self, audio_bytes_io, audio_settings=None):
self.check_connection()
try:
if audio_settings is None:
audio_settings = {"sr": 48000, "channels": 2}
self.manager.tts_input_q().put({"bytes_io":audio_bytes_io,"audio_settings":audio_settings})
out = self.manager.tts_output_q().get()
return out
except BaseException as err:
print('[DOCKER TTS SEND] Ошибка',err,' ПЕРЕПОДКЛЮЧЕНИЕ!')
self.check_connection(force=True)
def TranscribeTest(docker_sender):
inp = file_to_bytes_io("baya synth.wav")
audio_settings = {"sr":48000,"channels":1}
print('sended input') # ,inp)
result = docker_sender.transcribe(audio_bytes_io=inp,audio_settings=audio_settings)
print('got output =',result)
def LLMTest(docker_sender):
print('sended input LLM') # #ninp,context,paramsOverride,environment,lmUsername
result = docker_sender.chatbot("Как дела зшщз", "", None, {"env": "yt", "diags_count":0}, "ChoLexe")
print('got LLM output =',result)
if __name__ == "__main__":
# ТОЛЬКО ДЛЯ ДЕБАЖИНГА!!!
def debugFunc():
print('DEBUG FUNC! DEACTIVATED FUNCTIONALITY')
pass #do stuff
get_or_run_docker_container()
print('EXITING..')
exit()
#debugFunc()
#kill_docker_reciever()
docker_sender = DockerSender()
from multiprocessing import Process
#Process(target=lambda a: print("Hello, {}".format(a)), args=(["world"])).start()
Process(target=LLMTest, args=(docker_sender,)).start()
Process(target=TranscribeTest, args=(docker_sender,)).start()
Process(target=LLMTest, args=(docker_sender,)).start()
Process(target=TranscribeTest, args=(docker_sender,)).start()
Process(target=TranscribeTest, args=(docker_sender,)).start()
Process(target=LLMTest, args=(docker_sender,)).start()
Process(target=LLMTest, args=(docker_sender,)).start()
print('ALL PROCESSES STARTED')
time.sleep(15)
print('TERMINATING!')
exit()
#proc1.join()
#a = input("poka")
Теперь, когда вся «инфраструктура» готова, вносим связующую функцию в главный скрипт:
Кодопомойка main.py/FredT5Chatbot
from LLMExamples import LLMExamples, get_llm_formed_inputs
def FredT5ChatbotQueue(ninp, context, paramsOverride, environment, lmUsername):
llm_input, params, danger_context = get_llm_formed_inputs(inp=ninp, username=lmUsername,
params_override=paramsOverride,
environment=environment, dialog_context=context,
repeating_dict=repeating_dict)
return docker_sender.chatbot(llm_input, params, danger_context)
# keywords = {"context": context, "paramsOverride": paramsOverride, "environment": environment, "lmUsername": lmUsername}
# return docker_sender.chatbot((ninp,keywords))
# FredInputQueue.put((ninp,keywords))
# return FredOutputQueue.get()
def FredT5Chatbot(inp, authorisedUser="default", paramsOverride=None, environment_data=None):
global LogChat
printPref = "FREDT5>"
if inp:
startTime = datetime.now()
isEvent = False
old_mood = ctx.mood
if environment_data:
authorisedUser = environment_data.get("user",
authorisedUser) # DB_getUserYTName(authorisedUser, pref='')
returnOut = environment_data
if environment_data.get("env", "") == "minecraft":
pass
elif environment_data.get("env", "") == "minecraft_event":
isEvent = True
else:
environment_data = {"env": "youtube", "user": authorisedUser, "user_id": 5}
returnOut = {}
# prompt = []
# prompt.extend(DB_GetContext(authorisedUser, fredFormat=True))#DB_GetUserDiags(authorisedUser))
if (authorisedUser.strip() == ""):
authorisedUser = "default"
db_user_id = environment_data.get("user_id", 5)
db_nick_id = environment_data.get("nick_id", None)
# if(authorisedUser!="default"):
# usr = authorisedUser.replace('_MC_REG','')
if not isEvent:
diags_cnt = DATABASE.get_user_diag_count(user_id=db_user_id, real=True)
choo = (True, True, True, False,)
if db_nick_id and DATABASE.get_nick_analyze(db_nick_id, return_bool=True):
print('[DEBUG NEW CHATBOT]debug nick analyze True, nick_id =', db_nick_id)
choo = (True, False, False, False,)
environment_data["diags_count"] = diags_cnt
if (diags_cnt == 0 and random.choice(choo)) \
or "анализируй ник" in inp:
environment_data["do_nick_analyze"] = True
rankChange = 0
mood_modifer_filter = 0
filter_allowed = environment_data.get("filter_allowed", None)
bad_topics = ""
if filter_allowed is not None:
filter_topics = environment_data.get("filter_topics", [])
filter_score = environment_data.get("filter_score", 0)
rankChange += filter_score / 3
rankChange += (int(filter_allowed) - 1) / 3
environment_data["user_rank"] = DATABASE.add_to_user_rank(user_id=db_user_id,
amount=rankChange) # environment_data.get("user_rank",0)#DB_setUserRank(authorisedUser, rankChange)
# modifyMood(rankChange / 2)
mood_modifer_filter += (rankChange / 2)
environment_data["filter_allow"] = filter_allowed
if len(filter_topics) > 0:
topicsTranslatorMas = {"politics": "политикан", "racism": "расист", "religion": "религовед",
"terrorism": "террорист", "suicide": "самоубийца",
"offline_crime": "убийца", "drugs": "наркоман",
"social_injustice": "нытик",
"pornography": "пошляк", "prostitution": "сутенёр", "sexism": "сексист",
"sexual_minorities": "извращенец",
"online_crime": "скамер", "weapons": "стреляка",
"body_shaming": "жирдяй", "health_shaming": "инвалидыч",
"slavery": "рабыня", "gambling": "азартник"}
for topic in filter_topics:
add = topicsTranslatorMas.get(topic, "нытик")
bad_topics += ' ' + add
if add == "нытик":
print('[FREDT5 MAIN THREAD] [!!!!!!! DEBUG] не найден топик', topic, 'вернуто нытик')
print('FREDT5 DEBUG >>rankDebug>> filtScore', filter_score, 'allow',
int(filter_allowed),
'rankChange', rankChange, 'newRank', environment_data["user_rank"])
source_filter_topics = environment_data.get("filter_topics", [])
source_filter_topics_str = ' '.join(source_filter_topics)
environment_data["filter_topics"] = bad_topics
if environment_data.get("user_rank", None) is None:
environment_data["user_rank"] = DATABASE.get_user_rank(user_id=db_user_id)
oldrank = environment_data.get("user_rank", 0)
context = DATABASE.get_relevant_diag(user_id=db_user_id, count=2, exact_user_timeout=160.0,
any_user_timeout=280.0)
try:
stream_data = obs_ka.get_stream_status()
except BaseException as err:
print('ERR OBS READING STREAM DATA (GETTING STREAM STATUS) in chat answerer, err:', err)
stream_data = {"outputActive": False}
print('traceback:', traceback.format_exc())
environment_data["stream_data"] = {"started": stream_data["outputActive"],
"duration": stream_data.get("outputDuration", -1)}
answer = FredT5ChatbotQueue(ninp=inp, context=context, paramsOverride=paramsOverride,
environment=environment_data, lmUsername=authorisedUser)
# print(printPref,'ответ получен, ')
repeat = False
for record in context:
if answer["stopped"] == "repeat" or isSimilar(answer["reply"], record.get("msg", "")):
repeat = True
break
if repeat:
print('ПОВТОРЕНИЕ! Перезапуск без контекста')
answer = FredT5ChatbotQueue(ninp=inp, context=None, paramsOverride=paramsOverride,
environment=environment_data, lmUsername=authorisedUser)
usage_tokens = answer["tokens"]
emotion = answer["emotion"]
command = answer["command"]
reply = answer["reply"]
print('[FT5 ANSER! DEB] REPLY ДО ОБРАБОТКИ!', reply, "эмц, кмд=", emotion, command)
if not reply or reply.strip() == "" or len(reply.strip()) < 2:
void_phrases = ["Мне нечего сказать...", "Без комментариев...",
"Я не услышала, можешь повторить пожалуйста?",
"Повторите пожалуйста?",
"Зис дескрайбер из нот авелибал нау, плиз, кал бэк лэйтер",
"Абонент временно недоступен, перезвоните позже."]
reply = random.choice(void_phrases)
while (reply.find('\n\n') != -1):
reply = reply.replace('\n\n', '\n')
answer_filter = FiltersQueue(reply) # filt.Filter(msg)
answer_filter_topics = answer_filter["topics"]
answer_filter_topics_str = ' '.join(answer_filter_topics)
print('[FT5 ANS DEBUG FILTER] filter,msg', answer_filter)
own_msg_filtered = False
reply_without_filter = reply
if (answer_filter["score"] <= -10 and not filter_allowed):
print('[FT5 FILTER ERR] СООБЩЕНИЕ НЕ ПРОШЛО ФИЛЬТРАЦИЮ! Блокируем!')
filtered_phrases = ["Отфильтровано",
"Похоже, я хотела сказать что-то очень плохое",
"Сообщение не прошло фильтрацию",
"Упс, кажется я хотела сказать что-то гадкое",
"Я очень плохая девочка",
"Простите, но я не могу сказать то, о чем я подумала, кажется, это что-то очень плохое",
"Модератор решил, что в этом случае мне лучше промолчать"
]
reply_without_filter = reply # запоминаем отфильтрованный ответ чтоб потом отдать в дс
reply = random.choice(filtered_phrases)
own_msg_filtered = True
# ans["filter_score"] = filter["score"]
# question["filter_allowed"] = filter["allow"]
# question["filter_topics"] = filter["topics"]
print(printPref, "получили ответ >>", reply, "<<\n", "ЭМОЦИЯ=" + col(emotion, "green", True),
"КОМАНДА=" + col(answer["command"], "green", True))
rankChange = EmotionToRank(emotion)
modifyMood(mood_modifer_filter + (rankChange / 2))
newUserRank = DATABASE.add_to_user_rank(user_id=db_user_id, amount=rankChange)
# LogChat.append({"role": "user", "content": inp})
# LogChat.append({"role": "assistant", "content": reply})
print(printPref, "Инфа о пользователе: ЮТНик=" + col(
authorisedUser) + f" ранг={col(newUserRank)} (+{col(rankChange)})" + f" настроение = {col(ctx.mood)} (+{col(old_mood - ctx.mood)}))")
print(printPref, "Использовано токенов >>", bcolors.WARNING, usage_tokens, bcolors.ENDC, "<<",
"Времени затрачено", calcTime(startTime))
# discord post
if stream_data["outputActive"]:
ds_timecode = stream_data["outputTimecode"].split(".")[0]
else:
ds_timecode = eztime_min()
if environment_data.get("filter_allowed", False):
discord_filter_phrase = ""
else:
discord_filter_phrase = "! "
ds_event_phrase = " (" + environment_data.get("env", "?") + ")" + " Событие " + environment_data.get(
"type", "") if isEvent else f"""<{round(newUserRank, 2)}> {authorisedUser}""" + " "
for_discord_msg = f"""Q:> [{ds_timecode}]{ds_event_phrase}: {inp} ({discord_filter_phrase}{source_filter_topics_str})
A:> NetTyan: {reply_without_filter} ({answer_filter_topics_str})
-----"""
if own_msg_filtered:
discord_filtered_post(for_discord_msg)
else:
discord_msg_post(for_discord_msg)
if not isEvent:
ezdate = eztime()
# {'outputActive': False, 'outputBytes': 0, 'outputCongestion': 0.0, 'outputDuration': 0, 'outputReconnecting': False, 'outputSkippedFrames': 0, 'outputTimecode': '00:00:00.000', 'outputTotalFrames': 0}
DiagToLog = [
{"user": authorisedUser, "role": "user", "content": inp,
"date": environment_data.get("date", ezdate),
"emotion": "", "command": "",
"filter_allowed": environment_data.get("filter_allowed", None),
"filter_topics": source_filter_topics_str,
},
{"user": "NetTyan", "role": "assistant", "content": reply, "date": ezdate,
"emotion": emotion, "command": command,
"filter_allowed": answer_filter["allow"],
"filter_topics": answer_filter_topics_str
}
# log("user", inp, emotion, user=authorisedUser),
# log("assistant", reply, emotion, command=answer["command"])
# log(role="assistant",content="None",emotion=""):
]
# DB_addLogUserDiag(authorisedUser, DiagToLog)
answer_has_questions = "?" in reply
if answer_has_questions:
DATABASE.set_user_last_question(user_id=db_user_id, question=reply)
nick_analyze_normal = environment_data.get("do_nick_analyze", False) and len(
reply) > 100 and db_nick_id
if nick_analyze_normal:
DATABASE.set_analyze_for_nick(db_nick_id, nick_analyze=reply)
DATABASE.add_diags(user_id=db_user_id, diag_to_add=DiagToLog, data=environment_data)
returnOut["user"] = authorisedUser
returnOut["reply"] = reply
returnOut["command"] = answer["command"]
returnOut["emotion"] = emotion
# replytrans = fixFemWords(translator.translate(reply,dest='ru').text)
return returnOut
А вот теперь одна из самых сложных (для меня) частей — механизм автоматического выбора комментария для ответа. Сразу подготовим его ко всем возможным средам — не только Minecraft, но и Discord, Youtube, Twitch и т.д. Методы, которые мы пока не внедрили (например, TTS) пока заменить пустышками (pass или return вместо кода, после def).
Кодопомойка main.py/QuestionChooser
def ChooseQuestion(questions, priority=""):
# {"user":"nickname","msg":"message","date":"25-03.245235","nicktype":"ytname"}
# PreviousUsers = ["lol",'4odedy']
ScoredQuestions = []
maxScore = -99999
bestQuestion = None
startTime = datetime.now()
for i, question in enumerate(questions):
if question.get("delete", False) == True:
print('пропускаем вопрос, он должен быть удален')
continue
user = question.get("user", "")
# DB_getUserNicknames()
msg = question.get("msg", "")
msg = msg.strip()
words = wordtokenize(msg)
q_date = question.get("date", "")
if isinstance(q_date, str):
if q_date != "":
q_date = tm(q_date)
else:
q_date = datetime.now()
LastInteract = (datetime.now() - q_date).total_seconds()
if (LastInteract > 300):
print('DELETE QUESTION reason=time ', ctx_chat[i])
question["delete"] = True
# questions[i] = question
# ctx_chat[i] = question
chats_replace(ctx_chat, processing_timestamp=question["processing_timestamp"],
new_chat_entry=question,
this_array=questions)
continue
score = 0
user_id = None
if user.strip():
db_data = DATABASE.get_or_create_user(data=question, return_nick_id_too=True)
if db_data:
user_id = db_data["user_id"]
question["nick_id"] = db_data["nick_id"]
if user_id:
if user_id in ChooserPreviousUsers: # or AuthorizedUsers (+ High ranked + donaters)
score += 5
question["last_interact"] = DATABASE.get_user_last_interact_time(user_id=user_id,
last_answered=True)
question["user_id"] = user_id
user_rank = DATABASE.get_user_rank(user_id=user_id)
question["user_rank"] = user_rank
score += user_rank / 2
diags_count = DATABASE.get_user_diag_count(user_id=user_id, real=True)
# DEBUG DISABLED!! ПОТОМ НАДО ДОПИЛИТЬ
# TODO TODO TODO
# collect_all_chat_user_msgs(ctx_chat,
# processing_timestamp=question["processing_timestamp"],
# this_chat_entry=question, this_array=questions)
if diags_count is not None:
if (diags_count > 10):
score += 1
ChooserPreviousUsers[0] = user_id
else:
print('[CHOOSER] CANT ADD OR GET USER!!!')
continue
q_changed = False
if question.get("filter_allowed", None) is None:
filter = FiltersQueue(msg) # filt.Filter(msg)
# print('filter,msg', filter,msg)
question["filter_score"] = filter["score"]
question["filter_allowed"] = filter["allow"]
question["filter_topics"] = filter["topics"]
# questions[i] = question
# ctx_chat[i] = question
q_changed = True
chats_replace(ctx_chat, processing_timestamp=question["processing_timestamp"],
new_chat_entry=question, this_array=questions)
###ctx_chat = list(questions)
if question.get("sentence_type", None) is None:
question_analysis = FiltersQueue(msg, filter_type="info") # filt.Filter(msg)
question["sentence_type"] = question_analysis["sentence_type"]
q_changed = True
if q_changed:
chats_replace(ctx_chat, processing_timestamp=question["processing_timestamp"],
new_chat_entry=question, this_array=questions)
filter_score = question["filter_score"]
filter_allowed = question["filter_allowed"]
filter_topics = question["filter_topics"]
# print("debug QUEST 0FilterResults>>>", question.get("FilterResults", ""))
# print("debug QUEST FilterResults>>>", questions[i].get("FilterResults", ""))
# print("debug QUEST CHAT FilterResults>>>",ctx_chat[i].get("FilterResults",""))
if (not question.get("env", "") in ["youtube", "twitch", "trovo"]) and priority == "youtube":
continue
# msg=""
# todo bypass filter for some users (devs?)
if not ((filter_score <= -10 and not filter_allowed) or not msg): # and LastInteract>120):
## ОПРЕДЕЛЕНИЕ СРЕДЫ, ЮТУБЕРАМ +10
if question.get("env", "") == "youtube":
score += 5
elif question.get("env", "") == "discord":
score += 10 # old 4 todo find premium and add +100
elif question.get("env", "") == "twitch" or question.get("env", "") == "trovo":
score += 3
if question.get("priority_group", "") == "max":
score += 100
## АВТОРИЗАЦИЯ ##
## ОПРЕДЕЛЕНИЕ ОБРАЩЕНИЯ ##
for word in words:
nickSim = MasSimilarity(word, botNicknames)
if nickSim > 95:
score += 20
break
elif nickSim > 75: # def isSimilarMas(example,mas,val=75):
score += 15
break
elif MasSimilarity(word, botRelativesL1) > 75:
score += 10
break
elif MasSimilarity(word, botRelativesL2) > 75:
score += 5
break
## КАЧЕСТВО ТЕКСТА ##
# первая буква маленькая
py_clip = lambda x, l, u: l if x < l else u if x > u else x
if msg[0].islower():
score -= 0.1
else:
score += 0.2
# сообщение 8 и более символов интереснее
if len(msg) >= 10:
score += 1
else:
score -= 1
# ранжировка по дате (предпочтительны более ранние но релевантные вопросы)
scored = question # copy.deepcopy(question)
if LastInteract < 10:
pass
# score+=LastInteract/50
else:
scored["processing"] = "queue"
# рассчитываем время последнего общения КОНКРЕТНО с данным пользователем
def calc_user_last_interact(last_answerred: bool = False) -> datetime.date:
if last_answerred:
last_interact_time_string = DATABASE.get_user_last_question_date(user_id)
if not last_interact_time_string:
last_interact_time_string = None
else:
last_interact_time_string = question.get("last_interact", None)
if last_interact_time_string:
last_interact_date = tm(last_interact_time_string)
else:
last_interact_date = datetime(2022, 12, 25)
return (datetime.now() - last_interact_date).total_seconds()
user_last_interact = calc_user_last_interact(False)
user_last_questioned = calc_user_last_interact(True)
# меньше 80 сек назад говорили с этим пользователем?
if user_last_interact < 80:
score += 3
print('[DEBUG NEW] Q chooser: SCORE+3 (недавний ответ)')
# спрашивали ли бы пользователя о чем-то последний раз
if user_last_questioned < 100:
print('[DEBUG NEW] Q chooser: SCORE+10 (БЫЛ НЕДАВНО СПРОШЕН!)')
score += 10
# if LastInteract<100:
# else:
# score+=-100+py_clip((LastInteract-30)/200,0,60)
# калькуляция по фильтру и темам
if filter_allowed:
score += filter_score
else:
score += -abs(filter_score * 2.3)
scored["score"] = score
scored["LastInteract"] = LastInteract
if (score > maxScore and LastInteract < 10):
maxScore = score
scored["processing"] = "bestchosen"
bestQuestion = scored
# scored["processing"] ="pending"
ScoredQuestions.append(scored)
else:
print('DELETE QUESTION reason=filterScore or msg=""', ctx_chat[i])
question["delete"] = True
chats_replace(ctx_chat, processing_timestamp=question["processing_timestamp"],
new_chat_entry=question,
this_array=questions)
# return {"delete":True,"deleteIndex":i}
if len(ScoredQuestions) > 0:
# print('DEBUG ScoredQuestions ',ScoredQuestions)
timeMult = 1
while bestQuestion is None:
timeMult *= 2
# print('DEBUG все вопросы олдовые. Выбран будет лучший среди них')
maxScore = -99999
for question in ScoredQuestions:
score = question["score"]
if (score > maxScore and question["LastInteract"] < 10 * timeMult):
maxScore = score
bestQuestion = question
if timeMult > 300: # было 10000
# print("FILTER CHOOSER Времени затрачено", calcTime(startTime))
# return {"delete":True}
return None
# print("FILTER CHOOSER Времени затрачено", calcTime(startTime))
return bestQuestion
# for question in ScoredQuestions:
### DEBUG CHOOSER ###
def ChooseQuestionTest():
# filt.CheckModel()
questionMassive = [
{"user": "unknown", "msg": "Привет! Как дела?", "date": "2023-06-17 22:21:10"}, # %Y-%m-%d %H:%M:%S
{"user": "unknown", "msg": "ну здарова че", "date": "2023-06-17 22:21:10"},
{"user": "unknown", "msg": "Ну здарова че", "date": "2023-06-17 22:21:10"},
{"user": "unknown", "msg": "привет тянка", "date": "2023-06-17 22:21:08"},
{"user": "4odedy", "msg": "привет тянка", "date": "2023-06-17 22:21:08"},
]
print('DEBUG CHOOSER >>', ChooseQuestion(questionMassive))
# ChooseQuestionTest()
### DEBUG CHOOSER END ###
donationQueue = manager.list()
ctx_chat = manager.list()
def ctx_chat_replace(ctx_chat, processing_timestamp: int, new_chat_entry: dict) -> bool:
start_ctx_chat_len = len(ctx_chat)
for idx, chat_entry in enumerate(ctx_chat):
if chat_entry.get("processing_timestamp", -1) == processing_timestamp:
if start_ctx_chat_len != len(ctx_chat):
print(
f"[CTX CHAT WARNING ERR] ВНИМАНИЕ!!! КОЛИЧЕСТВО ЧАТА ИЗМЕНИЛОСЬ В ПРОЦЕССЕ: {str(start_ctx_chat_len)} -> {str(len(ctx_chat))}! Ячейка:",
chat_entry)
ctx_chat[idx] = new_chat_entry
return True
return False
def inner_chat_replace(processing_timestamp: int, new_chat_entry: dict, this_array: list = None) -> bool:
start_ctx_chat_len = len(this_array)
for idx, chat_entry in enumerate(this_array):
if chat_entry.get("processing_timestamp", -1) == processing_timestamp:
if start_ctx_chat_len != len(this_array):
print(
f"[INNER CHAT WARNING ERR] ВНИМАНИЕ!!! КОЛИЧЕСТВО ЧАТА ИЗМЕНИЛОСЬ В ПРОЦЕССЕ: {str(start_ctx_chat_len)} -> {str(len(this_array))}! Ячейка:",
chat_entry)
this_array[idx] = new_chat_entry
return True
return False
# processing_timestamp=question["processing_timestamp"],this_chat_entry=question,this_array=questions)
def collect_all_chat_user_msgs(ctx_chat, processing_timestamp: int, this_chat_entry: dict,
this_array: list = None) -> dict:
if this_array is None:
this_array = ctx_chat
if this_chat_entry.get("delete", False):
return this_chat_entry
start_ctx_chat_len = len(this_array)
result_msg = ""
this_date = this_chat_entry.get("date", "")
if isinstance(this_date, str):
if this_date != "":
this_date = tm(this_date)
else:
this_date = datetime.now()
changed = False
for idx, chat_entry in enumerate(this_array):
if chat_entry.get("delete", False) == True:
print('[debug chat ctx] delete tag, пропускаем')
continue
own_msg = False
if chat_entry.get("processing_timestamp",
-1) == processing_timestamp: # встретили то же сообщение что и проверяем
# upd: ***** делать не надо. Пусть оно проверяется и в результат
own_msg = True
if start_ctx_chat_len != len(this_array):
print(
f"[INNER CHAT WARNING ERR] ВНИМАНИЕ!!! КОЛИЧЕСТВО ЧАТА ИЗМЕНИЛОСЬ В ПРОЦЕССЕ: {str(start_ctx_chat_len)} -> {str(len(this_array))}! Ячейка:",
chat_entry)
# continue
if chat_entry.get("user", "") == this_chat_entry.get("user", ""):
if changed:
result_msg += "\n"
result_msg += chat_entry.get("msg", "").strip()
if not own_msg:
chat_entry["delete"] = True
this_array[idx] = chat_entry
chats_replace(ctx_chat=ctx_chat, processing_timestamp=chat_entry["processing_timestamp"],
new_chat_entry=chat_entry, this_array=this_array)
oldest_date = this_chat_entry.get("date", "")
oldest_date_time = None
if isinstance(oldest_date, str):
if oldest_date != "":
oldest_date_time = tm(oldest_date)
else:
oldest_date_time = datetime.now()
if this_date is not None and oldest_date_time is not None:
if oldest_date_time > this_date:
this_chat_entry["date"] = oldest_date_time
changed = True
print('[DEBUG CTX CHAT] MERGING MSGS', result_msg, 'from user', chat_entry.get("user", ""))
if changed:
this_chat_entry["msg"] = result_msg
chats_replace(ctx_chat=ctx_chat, processing_timestamp=processing_timestamp,
new_chat_entry=this_chat_entry, this_array=this_array)
return this_chat_entry
def chats_replace(ctx_chat, processing_timestamp: int, new_chat_entry: dict, this_array: list = None) -> bool:
return inner_chat_replace(processing_timestamp, new_chat_entry, this_array) \
and ctx_chat_replace(ctx_chat, processing_timestamp, new_chat_entry)
Кодопомойка main.py/CentralDecisionMaker
from FredT5 import CutSpaces
def SplitTextToParts(text: str, max_length: int = 150, prefix: str = "") -> list:
result = ""
resultmas = []
k = 0
for i, char in enumerate(text):
# print(i,'suka') нумерация с 0
if k == 0:
k += len(prefix)
k += 1
result += char
if (k >= max_length * 0.85):
if char in " .!?":
k = max_length
if (k >= max_length):
# print('4o ',k,max_length,resultmas)
resultmas.append(prefix + result.strip())
result = ""
k = 0
elif (i == len(text) - 1):
resultmas.append(prefix + result)
return resultmas
def CutMaxNumbers(inp):
out = ""
i = 0
for char in inp:
if char.isdigit():
i += 1
if i <= 5:
out += char
else:
out += char
if len(out.strip()) == 0:
out = out + "эм"
return out
def PrepareForChatPrint(inp):
restrictedChars = """\n\r|!'=#".,-/\\&^%$#@{}[]()*"""
for char in restrictedChars:
inp = inp.replace(char, " ")
inp = translit(CutSpaces(inp))
if len(inp) < 2:
inp = inp + "лол"
return inp
def ChatOwnRepeatDetect(inp, val=75):
for msg in ctx_chatOwn:
if isSimilar(msg["msg"], inp, val):
return True
return False
# isSimilarMas(chatprint, ctx_chatOwn, 75)
# tracker.print_diff()
# warnings.filterwarnings("ignore", message="torch.distributed.reduce_op is deprecated")
def TimeEventsCheck(timeEvent: str, sec=15):
timeEventTime = ctx.timeEvents.get(timeEvent, None)
if timeEventTime is None:
timeEventTime = datetime.now() - timedelta(seconds=500.0)
else:
timeEventTime = tm(timeEventTime)
return (datetime.now() - timeEventTime).total_seconds() > sec
##############################
### CENTRAL DECISION MAKER ###
##############################
ctx.stream_started = False
from string_utils import NonRepeatRandom
from string_utils import NonRepeatRandom
def CentralDecisionMaker():
"""ГЛАВНОЕ СРЕДСТВО УПРАВЛЕНИЯ"""
def BroadcastProcesser():
nrr = NonRepeatRandom(repeating_dict)
bc_type = nrr.r("stream_ad,status_report", key="decision_broadcast")
answer = FredT5Chatbot("пустота", authorisedUser="default",
environment_data={"env": "broadcast", "broadcast_type": bc_type,
"i_mood": ctx.mood,
"ingame_info": dict(ctx.ingame_info), })
sendToMCChat(answer["reply"], "default", type="stream_ad", doVoice=True)
return True
def DonateProcesser():
donation_answer_performed = False
# print(donationQueue)
if len(donationQueue) > 0:
donat = donationQueue[0]
name = donat.get("username", "").strip()
msg = donat.get("message", "").strip()
try:
summ = float(donat.get("amount", 0))
if summ > 0:
if name == "":
name = "анонист"
data_to_db = {"env": "donation", "summ": summ, "date": eztime()}
else:
# ДОБАВИТЬ В БАЗУ ДАННЫХ! ЭТО ПАМЯТЬ!
data_to_db = {"user": name, "somm": summ, "env": "donation", "msg": msg,
"date": eztime()}
if msg == "":
msg = "пустое сообщение"
print(' ДОНАТ ПРОЦЕССЕР АКТИВИРОВАН ! ВЫВОДИМ ДОНАТ', donat)
answer = FredT5Chatbot(msg, authorisedUser=name, environment_data=data_to_db)
textToSpeech("... " + name + ".. " + answer["reply"], "medium", "medium",
seeChat=False)
else:
textToSpeech(f"Ой спасибо.. Дорогой {name}, спасибо за большое подписочку! Няяяяяяяяяяяяяяяяяя",
"medium", "medium", # "няя" для рофлового протяжного звука
seeChat=False)
donation_answer_performed = True
except BaseException as err:
print('ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
print('ОШИБКА В ДОНАТИОН АЛЕРТС!', err)
donation_answer_performed = False
donationQueue.pop(0)
return donation_answer_performed
def EventProcesser():
event_answer_performed = False
if ctx.ingame:
if len(ctx.eventlist) > 0:
# lastevent = ctx.eventlist[-1]
for i, event in enumerate(ctx.eventlist):
name = event.get("user", "")
type = event.get("type", "")
LastInteract = (datetime.now() - tm(event.get("date"))).total_seconds()
if (LastInteract < 14 and len(ctx_chat) > 0) or (LastInteract < 25 and len(ctx_chat) <= 0):
msg = "ахахах"
eventToAdd = dict(event)
eventToAdd["env"] = "minecraft_event"
answer = FredT5Chatbot(msg, authorisedUser=name, environment_data=eventToAdd)
sendToMCChat(answer["reply"], usr=name,
type="minechat_answer",
doVoice=True)
ctx.eventlist[:] = []
if event_answer_performed:
ctx.timeEvents["last_mineevent_answer"] = eztime()
return event_answer_performed
def sendToMCChat(inp: str, usr=None, doVoice=True, type="minechat_answer") -> bool:
chat_answer_performed = False
chatprint = PrepareForChatPrint(inp)
voiceprefix = ""
if type == "minechat_answer":
usrTrans = translit(usr) + " "
voiceprefix = random.choice(
[usrTrans + ". "])
if not chatprint.find(usrTrans) != -1:
chatprint = usrTrans + chatprint
chatPrintMas = []
maxAllowedServerMsg = 140
serv = ctx.GameInfo.get("server", "") # server, serverMode, chatType
mode = ctx.GameInfo.get("serverMode", "")
prefix = ""
print('serv,mod =', serv, mode)
if serv == "mc.vimemc.net" and mode == "thepit":
maxAllowedServerMsg = 100
print('limit changed')
elif serv == "funnymc.ru" and mode == "skywars":
maxAllowedServerMsg = 95
print('limit changed FMC Pref /g')
prefix = "/g "
elif mode == "survival":
prefix = "!"
chatPrintMas = SplitTextToParts(chatprint, maxAllowedServerMsg, prefix=prefix)
# chatprint=chatprint[0:147]
print('чат парт ответа разделен на части: ', chatPrintMas)
repeating = False
for i, chatPart in enumerate(chatPrintMas):
if (len(chatPrintMas) > 0):
chatPrintMas[i] = CutMaxNumbers(chatPart)
if ChatOwnRepeatDetect(chatPart):
repeating = True
chat_answer_performed = False
if not repeating:
ctx.BridgeChatQueue.extend(chatPrintMas)
chat_answer_performed = True
###ctx.BridgeChatQueue = ctx.BridgeChatQueue + list(chatPrintMas)
if doVoice:
textToSpeech(voiceprefix + inp, "medium", "medium", seeChat=False)
else:
print('MC REPEAT DETECT!')
if chat_answer_performed:
ctx.timeEvents["last_" + type] = eztime()
return chat_answer_performed
def CentralChatProcesser(priority="minecraft"):
central_chat_answer_performed = False
q = None
def clear_deleted_from_ctx_chat():
for lol in ctx_chat:
if lol.get("delete", False) == True:
ii = ctx_chat.index(lol)
print('DELETE QUESTION IN PROC!!! index =', ii, '; q =', lol)
if (ii >= 0 and ii < len(ctx_chat)):
ctx_chat.pop(ii)
if True:
if (len(ctx_chat) > 0):
# tracker = SummaryTracker()
### PREPARING CHAT ###
for chat_entry in ctx_chat:
collect_all_chat_user_msgs(ctx_chat=ctx_chat,
processing_timestamp=chat_entry["processing_timestamp"],
this_chat_entry=chat_entry)
clear_deleted_from_ctx_chat()
q = ChooseQuestion(ctx_chat, priority=priority)
### CLEARING CHAT ###
clear_deleted_from_ctx_chat()
if q is not None:
print('len chat do:', len(ctx_chat))
idx = -100
for lol in ctx_chat:
if lol.get("processing_timestamp", -1) == q.get("processing_timestamp", -1):
idx = ctx_chat.index(lol)
if q.get("env", "") == "minecraft":
if not ctx.ingame:
q = None
if idx >= 0:
ctx_chat.pop(idx)
print('len chat posle:', len(ctx_chat))
if q is not None and q != {} and q != [] and q["msg"].strip() != "":
central_chat_answer_performed = True
if ctx.ingame and q.get("env", "") == "minecraft":
# ВЫПИЛИТЬ ОТВЕЧЕННЫЙ ЭЛЕМЕНТ ИЗ МАССИВА
# params = {"max_length":70}
print('ПЕРЕД ЗАПУСКОМ АНСВЕРА В ДЕСИЖН МАКЕРЕ q.get("filter_allowed", None) =',
q.get("filter_allowed", None))
answer = FredT5Chatbot(q["msg"], authorisedUser=q["user"], environment_data=q)
# if(ctx.BridgeChatQueue != []):
print(' !!! ВНИМАНИЕ !!! >>> ЗАПУСК АВТОМАТИЧЕСКОГО ОТВЕТА ИГРОКУ')
# здесь time events обновляет тайм внутри функции sendToMCChat
central_chat_answer_performed = sendToMCChat(answer["reply"], usr=q["user"],
type="minechat_answer",
doVoice=True) # inp, usr=None, prefixMas=[''], doVoice=False)
elif q.get("env", "") == "youtube":
print(' !!! ВНИМАНИЕ !!! >>> ЗАПУСК АВТОМАТИЧЕСКОГО ОТВЕТА В ЮТУБЕ')
answer = FredT5Chatbot(q["msg"], authorisedUser=q["user"], environment_data=q)
ctx.timeEvents["last_youtube_answer"] = eztime()
ban = ""
if answer.get("command", "") == "бан":
user_channel_id = q.get("youtube_user_channel_id", None)
if user_channel_id:
ban = "[забанить] "
print(f'Забанить {q["user"]} на 10s, ЗАБАНИТЬ =', ban)
ctx.YoutubeActionsQueue.append({"action": "ban", "ytname": q["user"],
"youtube_user_channel_id": user_channel_id,
"bantime": 10})
ctx.YoutubeActionsQueue.append(
{"action": "reply", "msg": f"""{ban}{q["user"]}. {answer["reply"]}"""})
textToSpeech("ютик " + q["user"] + ". " + answer["reply"], "medium",
"medium", seeChat=False)
elif q.get("env", "") == "twitch":
print(' !!! ВНИМАНИЕ !!! >>> ЗАПУСК АВТОМАТИЧЕСКОГО ОТВЕТА В TWITCH')
answer = FredT5Chatbot(q["msg"], authorisedUser=q["user"], environment_data=q)
twitch_actions_queue.put(
{"action": "reply", "msg": f"""{q["user"]}. {answer["reply"]}"""})
textToSpeech("твич " + q["user"] + ". " + answer["reply"], "medium",
"medium", seeChat=False)
elif q.get("env", "") == "trovo":
print(' !!! ВНИМАНИЕ !!! >>> ЗАПУСК АВТОМАТИЧЕСКОГО ОТВЕТА В TROVO')
answer = FredT5Chatbot(q["msg"], authorisedUser=q["user"], environment_data=q)
trovo_actions_queue.put(
{"action": "reply", "msg": f"""{q["user"]}. {answer["reply"]}"""})
textToSpeech("трово " + q["user"] + ". " + answer["reply"], "medium",
"medium", seeChat=False)
elif q.get("env", "") == "discord":
print(' !!! ВНИМАНИЕ !!! >>> ЗАПУСК АВТОМАТИЧЕСКОГО ОТВЕТА В DISCORD!!!')
answer = FredT5Chatbot(q["msg"], authorisedUser=q["user"], environment_data=q)
if not q.get("manual_instruct", True):
discord_mention_name = q["user"]
if "discord_id" in q:
discord_mention_name = "<@" + q["discord_id"] + ">"
DiscordTestMsgSend(
"[AI] ответ для " + discord_mention_name + " \n" + answer["reply"])
prefix_ans = "дис " + q["user"] + ". "
textToSpeech(prefix_ans + answer["reply"], "medium",
"medium", seeChat=False)
else:
sendToMCChat(answer["reply"], usr=q["user"],
type="stream_ad",
doVoice=True) # inp, usr=None, prefixMas=[''], doVoice=False)
else:
print('Ну че не ответили палучаеца')
central_chat_answer_performed = False
return central_chat_answer_performed
А вот теперь точно — вуаля! Теперь наша дорогая тян может общаться! Правда, на начальном этапе разработки «тормозов» у неё было, как видно из поведения: (а ещё там вместо БД на sqlite был просто json)
Первые шаги социализации (не самое приятное зрелище)
Так как на этом этапе у нас почти нет модерации, в поведении нашей подопытной можно заметить уклон в «мочеполовые» темы, обусловленный, прежде всего, токсичным поведением самих игроков в чате (но не всегда):
История угасшей любви...
Видимо, на этом моменте нейросеть «переполнили чувства» и она вырубилась. Серьёзно. Python-часть (с Фредом) тогда крашнулась, и этот парнёк остался без навсегда без ответа...
На первых этапах разработки особенно часто можно было встретить различные казусы... Чаще всего нейросеть входила в цикл повторения, но ещё возникали случаи, когда она могла, например, продолжить диалог за собеседника.
Баги (тоже весело)
Продолжаем отдыхать от кода: вот, держите ещё пару фрагментов, теперь уже когда наша тянка стала «поувереннее» в плане автомодерации.
Тотальный разнос чата
Добиваем остатки (TTS, Google API...)
На этом моменте я уже прям подустал писать статью, тем более, что всё самое основное и весёлое — позади, а теперь остался только сухой суровый код...
TTS
Создаём TTS процесс в нашем обычном стиле, с «очередью».
Кодопомойка main.py/TTS_PROCESS
def TTS_PROCESS(ctx):
import torch
# from torch import package
print('[TTS INIT] Started load TTS model...')
device = torch.device("cpu") # 'cpu') # cuda
torch.set_num_threads(4)
t = datetime.now()
local_file = thisfolder + '/AI_Models/TTS/variants/Silero/silero_tts.pt'
if not os.path.isfile(local_file):
torch.hub.download_url_to_file('https://models.silero.ai/models/tts/ru/v3_1_ru.pt',
local_file)
VoiceModel = torch.package.PackageImporter(local_file).load_pickle("tts_models", "model")
# VoiceModel, exampleText = torch.hub.load(repo_or_dir='snakers4/silero-models', model='silero_tts', language='ru',
# speaker='v3_1_ru', trust_repo=True, cache_dir=) # v3_1_ru или ru_v3
VoiceModel.to(device) # gpu or cpu
print('[TTS INIT] время запуска VOICE TTS на', device, '=', calcTime(t))
# VoiceModel.eval() не работает
print('[TTS INIT 2 NEW] Started load ACCENTUATOR model...')
# from ruaccent import RUAccent
#
# accentizer = RUAccent()
# custom_words_accent_dict = {"бовдур":"б+овдур","бовдурус":"б+овдурус"}
# accentizer.load(omograph_model_size='big', use_dictionary=True, custom_dict=custom_words_accent_dict)
# https://huggingface.co/TeraTTS/accentuator
print('[TTS INIT 2 NEW] ENDED! load ACCENTUATOR model! time =', calcTime(t))
ctx.loading_flag.set()
while True:
try:
text = ctx.Queue.get()
print('[VOICE QUEUE] получена очередь', text)
# text = accentizer.process_all(text)
# print('[VOICE QUEUE DEBUG NEW] ТЕКСТ С УДАРЕНИЯМИ >>', text)
# VoiceModel, exampleText = torch.hub.load(repo_or_dir='snakers4/silero-models', model='silero_tts',
# language='ru',
# speaker='v3_1_ru', trust_repo=True) # v3_1_ru или ru_v3
VoiceModel = torch.package.PackageImporter(local_file).load_pickle("tts_models", "model")
VoiceModel.to(device) # gpu or cpu
try:
audiowave = VoiceModel.apply_tts(ssml_text=text, speaker='baya', sample_rate=48000, put_accent=True,
put_yo=True)
except BaseException as err:
print("=== ПРОИЗОШЛА ОШИБКА", err, "В ГЕНЕРАЦИИ СИНТЕЗА РЕЧИ! ====\n")
print("=== ТЕКСТ>>" + text + "<< ====\n")
print('ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
print("\n=== КОНЕЦ ОШИБКИ ====")
audiowave = VoiceModel.apply_tts(text=text, speaker='baya', sample_rate=48000, put_accent=True,
put_yo=True)
ctx.QueueOutput.put(audiowave)
except BaseException as err:
print('[TTS ERR] ОШИБКА ПРОЦЕССА В TTS: ', err)
print('[TTS ERR] ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
print("\n[TTS ERR] === КОНЕЦ ОШИБКИ ====")
time.sleep(1)
Далее создадим функцию, с помощью которой будем выводить звук. Я реализовал систему таким образом, чтобы нейросеть могла отвечать и генерировать новый ответ прямо во время воспроизведения звука, чтобы ответы более быстрыми и релевантными комментариям.
Одновременно с воспроизведением передаём в работу текст с субтитрами. Его будем обрабатывать позже, когда будем делать связь с obs (стриминговой программой), чтобы субтитры появлялись на экране вместе с речью.
Кодопомойка main.py/textToSpeech
def emotions_to_str(text: str) -> str:
emotions_str_map = {"<3": "сердешко",
"^_^": "няааааа",
"^^": "няа",
":)": "улыбка",
":')": "плак",
":-)": "улыбка",
":(": "грусть",
":'(": "плак",
":-(": "печалька",
"(": "то есть",
}
for emo in emotions_str_map:
text = text.replace(emo, emotions_str_map[emo])
return text
translitLatin = lambda x: cyrtranslit.to_latin(x, "ru")
from num2words import num2words
def NumbersToSpeech(inp):
numbers = re.findall(r'\b\d+\b', inp)
result = inp
for numb in numbers:
print(numb, num2words(numb, lang='ru'))
result = result.replace(numb, num2words(numb, lang='ru'))
return result
def PrepareToSpeech(ninp, subtitles=False):
inp = ninp
result = ""
# print('.', end='')
if subtitles:
result = inp
else:
result = NumbersToSpeech(translit(inp)) # добавить опред. смайлов;
result = emotions_to_str(result)
if len(ninp) > 900:
inp = ninp[0:899]
return result
import pygame
from pygame import mixer # Playing sound
import pygame._sdl2.audio as sdl2_audio
init_by_me = not pygame.mixer.get_init()
if init_by_me:
pygame.mixer.init()
devices = tuple(sdl2_audio.get_audio_device_names())
if init_by_me:
pygame.mixer.quit()
# print(str(devices))
pygame.mixer.pre_init()
pygame.mixer.init(frequency=48000, size=-16, channels=2, buffer=7168,
devicename='CABLE-A Input (VB-Audio Cable A)') # Initialize it with the correct device
# sound_effect.play()
# pip install sounddevice
import sounddevice as sd
sd.default.samplerate = 48000
sd.default.channels = 2
sd.default.device = 'CABLE-A Input (VB-Audio Cable A), Windows DirectSound'
def SoundToMicro(file='test.wav', audio=None, sleep=False, smart_wait=False, change_emotes=False):
####pygame.init()
# pygame.mixer.init(devicename='CABLE Input (VB-Audio Virtual Cable)') #Initialize it with the correct device
# sound_effect = pygame.mixer.Sound('test.wav')
if sleep and smart_wait:
speech_available_event.clear()
if change_emotes:
ctx.SeparateEyes = False
ctx.state = "idle"
if audio is not None:
sd.play(audio, 48000 * 1.05)
if sleep:
time.sleep((len(audio) / 48000) + 0.5)
sd.stop()
else:
sound_effect = pygame.mixer.Sound(file)
sound_effect.play()
if change_emotes:
ctx.isVoiceBusy = False
ctx.SeparateEyes = False
ctx.state = "gaming"
if sleep and smart_wait:
speech_available_event.set()
def textToSpeech(text, rate="fast", pitch="medium", seeChat=False):
"""В версии 0.0.4 добавили особый блок: ждем, только если есть другая речь"""
print('Запускаем модель...')
startTime = datetime.now()
text = PrepareToSpeech(text)
subtitles = PrepareToSpeech(text, subtitles=True)
text = OformText(text, rate, pitch)
ctx.isVoiceBusy = True
if seeChat:
ctx.AnimEventInfo = {"name": "SawChat.exp3.json", "type": "expression", "time": 0.01}
ctx.AnimEvent.set()
print("Генерация файла wav... время до этого шага:", calcTime(startTime)) # +text)
audio = TTS_QUEUE(text)
print("Играем звук wav...", calcTime(startTime))
if seeChat:
ctx.AnimEvent.clear()
speech_available_event.wait()
TextDisplaySpeed.value = rate
textSubtitlesHttp.value = subtitles
# Thread
threading.Thread(target=SoundToMicro,
kwargs={"audio": audio, "sleep": True, "smart_wait": True, "change_emotes": True},
daemon=True).start()
# SoundToMicro(audio=audio, sleep=True)
# print_subtitles(subtitles,rate)
print("Голосовой вывод отправлен! Затраченное на всё про всё время =",
calcTime(startTime)) # закончен! Затраченное на всё про всё время
# vtube_ctx.NeedX = -0.5
# vtube_ctx.NeedY = -1
# ctx.AnimEvent.clear()
OBS
Свяжем нашу TTS-часть со стриминговой платформой, чтобы она выводила синхронные субтитры! Ну и дополнительно забабахаем связь через obs websocket, чтобы можно было управлять obs-кой прямо из скрипта программы.
К сожалению, полноценные синхронные субтитры я так и не сделал ввиду того, что Silero особо не предоставляет лёгкой возможности к синхронизации субтитров, а распознавать речь, которую мы сами и синтезируем, я не хочу. В общем, я сделал имитацию синхронных субтитров: работает она, конечно, так себе, но для прототипа может сгодиться...
Соединять субтитры с OBS будем посредством Flask web-приложения на внутренней сети. Такой подход позволит с лёгкость заменить стримерскую платформу, в случае чего (большинство из них поддерживают веб-наложение). Кроме субтитров, кстати, заодно, будем выводить и индикатор «настроения» системы.
Кодопомойка subtitles_web.py
import random
def clamp(n, smallest, largest): return max(smallest, min(n, largest))
def print_subtitles(inp,speed="fast",calculateTime=False):
import time
if not calculateTime:
print("== ВЫВОДИМ СУБТИТРЫ ==")
print("Субтитры>> ",end='', flush=True) # rate x-slow slow medium fast x-fastt
punktEnd = "!?."
if speed == "fast":
mult = 1
elif speed == "x-fast":
mult = 0.7
elif speed == "medium":
mult = 1.3
elif speed == "slow":
mult = 1.7
elif speed == "x-slow":
mult = 2.0
resulttime = 0
if inp:
for symbol in inp:
waittime = 0.04*mult
if symbol == " ":
waittime = 0.06*mult
elif symbol == ",":
waittime = 0.2*mult
elif punktEnd.find(symbol) != -1:
waittime = 0.4*mult
if(not calculateTime):
print(symbol,end='', flush=True)
time.sleep(waittime)
else:
resulttime+=waittime
if (not calculateTime):
print("\n==Субтитры выведены!==")
else:
#print('Время субтитров >>> '+str(resulttime))
return resulttime
def HttpAppRun(ctx,SubtText,TextDisplaySpeed,RefreshInterval,screenPrintMas):
import multiprocessing
from multiprocessing import Process, Manager
import threading
from flask import Flask, render_template
import flask
import subprocess
import time
import logging
log = logging.getLogger('werkzeug')
log.disabled = True
sitestring = ["чо деду чо бабке"]
app = Flask(__name__)
text_to_display = ""
newtext = "vzz"
####def update_text():
#### global text_to_display
#### global newtext
#### threading.Timer(0.01, update_text).start()
#### #print(newtext)
#### text_to_display = newtext
####
##### Start updating the text
####update_text()
oldval=""
def SplitTextToParts(text, max_length=150):
result = ""
resultmas = []
k = 0
for i, char in enumerate(text):
k += 1
result += char
if (k >= max_length * 0.85):
if char in " .!?":
k = max_length
if (k >= max_length):
# print('4o ',k,max_length,resultmas)
resultmas.append(result.strip())
result = ""
k = 0
elif (i == len(text) - 1):
resultmas.append(result)
return resultmas
def generate_text_shawdow(outline_color = '#000000',glow_color = '#ff5cef'):#outline_color = '#000000',glow_color = '#ff5cef'
return f"""<style type="text/css">
.OutlineText {{
text-shadow:
/* Outline 1 черный */
-1px -1px 0 {outline_color},
1px -1px 0 {outline_color},
-1px 1px 0 {outline_color},
1px 1px 0 {outline_color},
-2px 0 0 {outline_color},
2px 0 0 {outline_color},
0 2px 0 {outline_color},
0 -2px 0 {outline_color},
/* Outline 2 красный #ff0000 розовый #ff5cef */
-2px -2px 0 {glow_color},
2px -2px 0 {glow_color},
-2px 2px 0 {glow_color},
2px 2px 0 {glow_color},
-3px 0 0 {glow_color},
3px 0 0 {glow_color},
0 3px 0 {glow_color},
0 -3px 0 {glow_color};
}}
</style>"""
mood_list = [0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0]
import GPUtil
@app.route('/info/')
def systeminfo():
def inner():
mood = round(ctx.mood,2)
if(mood_list[9] != mood and mood_list[10]==0):
old_mood = mood_list[9]
mood_list[9] = mood
mood_subtract = mood - old_mood
mood_part = mood_subtract/10
#print('web debug',old_mood,mood,mood_subtract,mood_part,'\n',mood_list)
for i in range(0,9):
mood_list[i]=old_mood+(i*mood_part)
mood_list[10]=0
mood = round(mood_list[mood_list[10]],2)
if mood_list[10]>=9:
mood_list[10] = 0
refresh_time = 1
else:
refresh_time = 0.1
mood_list[10] = mood_list[10] + 1
red,green,blue = 255,255,255
emoji = "?"
if mood>=0.25:
lol = int(clamp(25+(mood // 0.039),0,255)) #green
red-=lol
blue-=lol
emoji = "?"
elif mood<=-0.25:
lol = int(clamp(25+((-mood) // 0.039), 0, 255)) #red
green-=lol
blue-=lol
emoji = "?"
#print('rgb',red,green,blue)
GPUs = GPUtil.getGPUs()
#gpu_load = "0%"
#if len(GPUs) > 0:
# gpu = GPUs[0]
# #print(gpu,gpu.load,gpu.name,gpu.memoryUtil)
# gpu_load = "{:.0%}".format(gpu.load)
yield f"""<head>
<title>Информация о системе</title>
{generate_text_shawdow(glow_color="rgba("+str(red)+","+str(green)+","+str(blue)+",100)")}
</head>
<body style="font-size:25pt; color:rgba(255,255,255,100); text-align:left; vertical-align:up"; align="center"> <font face="Minecraft Rus">
<div class="OutlineText">
<p>{emoji+" "+str(mood)}</p>
</div>
</body>
"""
#?
#green 255 10 0 0 0.039
#red 255 -10 0 0
yield f"""<meta http-equiv="refresh" content="{str(refresh_time)}">"""
return flask.Response(inner(), mimetype='text/html') # text/html is required for most browsers to show th$
@app.route('/')
@app.route('/subtitles/')
def index():
def inner():
if(SubtText.value!="" or len(screenPrintMas)!=0):
speed = TextDisplaySpeed.value
inp = SubtText.value
#color:rgba(255,6,132,100); сиреневый
#yield """<head> <link rel="stylesheet" href='/templates/static/main.css' /> </head> <body style="font-size:33pt; color:rgba(255,255,255,100); text-align:center; vertical-align:bottom"; align="center"> <font face="Impact">""" #align="center"
yield """
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Субтитры</title>"""
yield generate_text_shawdow()#align="center"
yield """</head> <body style="font-size:33pt; color:rgba(255,255,255,100); text-align:center; vertical-align:bottom"; align="center"> <font face="Impact"> <div class="OutlineText">"""
if inp and len(screenPrintMas)==0:
screenPrintMas.extend(SplitTextToParts(inp, 80))
if len(screenPrintMas)>0: #if inp это то же самое что inp!=""
inp = screenPrintMas.pop(0)
punktEnd = "!?."
if speed == "fast":
mult = 1
elif speed == "x-fast":
mult = 0.7
elif speed == "medium":
mult = 1.3
elif speed == "slow":
mult = 1.7
elif speed == "x-slow":
mult = 2.0
for i,symbol in enumerate(inp):
waittime = 0.04*mult
if symbol == " ":
waittime = 0.06*mult
elif symbol == ",":
waittime = 0.2*mult
elif punktEnd.find(symbol) != -1:
waittime = 0.4*mult
#print(symbol,end='', flush=True)
yield symbol#+'<br/>\n'
if not (i >= (len(inp)-1)): #на последнем символе отключаем ожидание
time.sleep(waittime)
yield """</div> </body>"""
if len(screenPrintMas)!=0:
refreshtime=0.1
else:
refreshtime=1.4
yield f"""<meta http-equiv="refresh" content="{str(refreshtime)}">""" #print_subtitles(SubtText.value,TextDisplaySpeed.value,True)/10
SubtText.value = ""
else:
yield f"""<meta http-equiv="refresh" content="0.1">"""
#if(SubtText.value)!=oldval:
# yield SubtText.value+'<br/>\n'
# oldval = SubtText.value
#else:
# yield SubtText.value+'<br/>\n'
#for line in iter(proc.stdout.readline,''):
#for line in SubtText.value:
# time.sleep(0.03) # Don't need this just shows the text streaming
# yield line#.rstrip() + '<br/>\n'
return flask.Response(inner(), mimetype='text/html') # text/html is required for most browsers to show th$
app.run()
Теперь внесём это в сцену самого obs и получим:
Индикатор настроения и субтитры. Важно также добавить, что Flask-часть рекомендуется запускать в отдельном процессе, либо в главном потоке процесса, но никак не в созданном для исключения конфликтов в программе.
Для внесения в OBS
Окно с субтитрами:http://localhost:5000/subtitles/
Окно с индикаторами:http://localhost:5000/info/
Теперь напишем интерфейс для работы с OBS из Python-скрипта:
Кодопомойка OBS_WS.py
from obswebsocket import obsws, events, requests
class OBS_Websocket():
host = "localhost"
port = 4455
password = "ПАРОЛЬ В WEBSOCKET OBS, ВКЛЮЧИТЕ ЕГО ТАМ ДЛЯ НАЧАЛА"
connected = False
ws = None
def check_connection(self):
if not self.connected:
try:
self.ws = obsws(self.host, self.port, self.password)
self.ws.connect()
self.connected = True
return True
except BaseException as err:
print('[OBS WS CONNECT ERR]',err)
self.connected = False
return False
else:
return True
def call_get(self, request):
try:
result = self.ws.call(request)
return dict(result.datain)
except BaseException as err:
print('[OBS WS REQ GET ERR]', err)
self.connected = False
return None
def call(self, request):
try:
self.ws.call(request)
return True
except BaseException as err:
print('[OBS WS REQ SIMPLE ERR]', err)
self.connected = False
return False
def get_stream_status(self) -> dict:
if self.check_connection():
stream_status = self.call_get(requests.GetStreamStatus())
if stream_status is not None:
return stream_status
return {"outputActive": False}
#https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#getstreamstatus
# outputActive outputReconnecting outputTimecode outputDuration outputCongestion outputBytes outputSkippedFrames outputTotalFrames
def set_scene(self, scene_name:str) -> bool: #NetTyanChat NetTyan NetTyan NetTyanDisclaimer
if self.check_connection():
return self.call(requests.SetCurrentProgramScene(sceneName=scene_name))
def set_record(self, enable:bool) -> bool:
if self.check_connection():
if enable:
return self.call(requests.StartRecord())
else:
return self.call(requests.StopRecord())
def set_stream(self, enable:bool) -> bool:
if self.check_connection():
if enable:
return self.call(requests.StartStream())
else:
return self.call(requests.StopStream())
Интерфейс работы со стриминговыми платформами Youtube, Twitch...
Для того, чтобы можно было писать и получать сообщения из чатов прямых трансляций мне пришлось регистрировать по приложению на порталах разработчиках Youtube (там рулит Google Cloud API) и dev.twitch. До кучи скажу, что я регал ещё и Trovo, правда трафика оттуда не пришло совсем, быть может потому, что на тот момент, как и сейчас, платформа не очень-то популярна. Хотя обидно, ведь я написал с нуля целый интерфейс для работы с ней, учитывая, что на тот момент не было даже удобных рабочих библиотек для работы с чатом с помощью Python... Хм, может выпустить библиотеку на GitHub, вдруг кому понадобится?
Кодопомойка Social_YT.py
import requests
import json
import threading
import os
# pip install python-dotenv
from dotenv import load_dotenv
# pip install google-auth-oauthlib
# pip install google-api-python-client
from google_auth_oauthlib.flow import InstalledAppFlow
import random
from googleapiclient.discovery import build
import traceback
print('imported AI YT0')
def eztime():
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def tm(x):
return datetime.strptime(x, '%Y-%m-%d %H:%M:%S')
print('imported AI YT01')
def YoutubeChatListener(ctx, twitch_actions_queue, trovo_actions_queue, ctx_chat):
print('0[PRE PRE PRE INIT YT + TWITCH] yt listener start....')
load_dotenv()
print('[PRE PRE PRE INIT YT + TWITCH] yt listener start....')
def CheckApp(youtube):
if youtube is None and ctx.YouTubeAppEnabled:
youtube = AuthorizeApp()
return youtube
def AuthorizeApp():
file = "zHyperAI_Social/youtube/client_secret.json"
flow = InstalledAppFlow.from_client_secrets_file(file, scopes={
'openid',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/youtube',
'https://www.googleapis.com/auth/youtube.force-ssl',
'https://www.googleapis.com/auth/youtube.readonly',
})
flow.run_local_server(
host='localhost',
port=5500,
authorization_prompt_message="")
credentials = flow.credentials
# Building the youtube object:
youtube = build('youtube', 'v3', credentials=credentials)
# Settings
_delay = 1
# https://github.com/shieldnet/Youtube-livestream-api-bot/blob/master/youtubechat/ytchat.py
# delete ban
# https://github.com/nategentile/ban_youtube_bots/blob/main/main.py
return youtube
youtube = None
liveChatId = None
def getLiveChatId(yt_liveChatId, LIVE_STREAM_ID):
nonlocal youtube
"""
It takes a live stream ID as input, and returns the live chat ID associated with that live stream
LIVE_STREAM_ID: The ID of the live stream
return: The live chat ID of the live stream.
"""
if yt_liveChatId is None and ctx.YouTubeAppEnabled:
stream = youtube.videos().list(
part="liveStreamingDetails",
id=LIVE_STREAM_ID, # Live stream ID
)
yt_response = stream.execute()
# print("\nLive Stream Details: ", json.dumps(response, indent=2))
yt_liveChatId = yt_response['items'][0]['liveStreamingDetails']['activeLiveChatId']
print("\nLive Chat ID: ", yt_liveChatId)
return yt_liveChatId
# Access user's channel Name:
def getUserName(userId):
"""
It takes a userId and returns the userName.
userId: The user's YouTube channel ID
return: User's Channel Name
"""
channelDetails = youtube.channels().list(
part="snippet",
id=userId,
)
yt_response = channelDetails.execute()
# print(json.dumps(response, indent=2))
userName = yt_response['items'][0]['snippet']['title']
return userName
def yt_execute(yt_snippet):
try:
response = yt_snippet.execute()
return response
except BaseException as err:
print('[YT ERR] err while executing:', err)
return False
def yt_exec(yt_snippet):
response = yt_execute(yt_snippet)
if response is False:
print('[YT ERR EXEC] провалена 1 попытка выполнить запрос, пробуем снова')
response = yt_execute(yt_snippet)
return response
# print(getUserName("UC0YXSy_J8uTDEr7YX_-d-sg"))
def tempban(yt_liveChatId, channel_id, timee=10):
nonlocal youtube
print('до попытки бана')
ban = youtube.liveChatBans().insert(
part="snippet",
body={
"snippet": {
"liveChatId": yt_liveChatId,
"type": "temporary",
"banDurationSeconds": timee,
"bannedUserDetails": {
"channelId": str(channel_id)
}
}
}
)
print("[YT LIVECHAT] BAN TO 4ell sent!", yt_exec(ban))
def sendReplyToLiveChat(yt_liveChatId, message):
nonlocal youtube
"""
It takes a liveChatId and a message, and sends the message to the live chat.
liveChatId: The ID of the live chat to which the message should be sent
message: The message you want to send to the chat
"""
if not isinstance(message, str):
message = "[AI] Сообщение ответа не прошло фильтрацию [ЭТАП4]."
if len(message) >= 200:
print('[YOUTUBE LIVECHAT] ДЛИНА СООБЩЕНИЯ ПЕРВЫСИЛО МАКСИМУМ!', len(message))
message = message[0:150]
reply = youtube.liveChatMessages().insert(
part="snippet",
body={
"snippet": {
"liveChatId": yt_liveChatId,
"type": "textMessageEvent",
"textMessageDetails": {
"messageText": message,
}
}
}
)
print("[YT CHAT BOT] Send message response:", yt_exec(reply))
def getYoutubeUserId(YOUTUBE_STREAM_API_KEY, YouTubeName):
channel_ids = requests.get(
f'https://www.googleapis.com/youtube/v3/search?part=id&q={YouTubeName}&type=channel&key={YOUTUBE_STREAM_API_KEY}').json()[
'items']
if len(channel_ids) > 0:
channel_id = channel_ids[0]['id']['channelId']
return channel_id
return None
# import time
# pip install pytchat
# Set API key and YouTube video ID
# добывается в гугл клауде https://console.cloud.google.com/apis/
from zHyperAI_Social.SocialConfigs import YOUTUBE_STREAM_API_KEY
# Set YouTube channel ID КАНАЛ ОТКУДА БЕРЕМ СТРИМ. ID канала узнать можно через код элемента поиск channel id
# [TEST] The Good Life Radio x Sensual Musique https://www.youtube.com/channel/UChs0pSaEoNLV4mevBFGaoKA
from zHyperAI_Social.SocialConfigs import CHANNEL_ID
# Get channel information
# чисто url самого канала и его описания иконки и т д
# url = f"https://www.googleapis.com/youtube/v3/channels?part=snippet%2CcontentDetails%2Cstatistics&id={CHANNEL_ID}&key={API_KEY}"
# юрл стрима текущего
YoutubeStreamURL = f"https://www.googleapis.com/youtube/v3/search?part=snippet&channelId={CHANNEL_ID}&eventType=live&type=video&key={YOUTUBE_STREAM_API_KEY}"
from twitchAPI import Twitch
from twitchAPI.oauth import UserAuthenticator
from twitchAPI.types import AuthScope, ChatEvent
from twitchAPI.chat import Chat, EventData, ChatMessage, ChatSub, ChatCommand
import asyncio
from zHyperAI_Social.SocialConfigs import TWITCH_APP_ID, TWITCH_APP_SECRET, TWITCH_TARGET_CHANNEL
TWITCH_USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT]
twitch_chat = None
async def twitch_chat_reply(ninp: str):
nonlocal twitch_chat
try:
await twitch_chat.send_message(TWITCH_TARGET_CHANNEL, ninp)
except BaseException as err:
print('[TWITCH LIVECHAT ERR] не удалось отправить сообщение', ninp, 'в twitch chat потому что', err)
async def on_ready(ready_event: EventData):
print('[TWTICH BOT LOAD] Bot is ready for work, joining channels')
await ready_event.chat.join_room(TWITCH_TARGET_CHANNEL)
await twitch_chat_reply(
f"[AI] [CONNECTED->{datetime.now().strftime('%M:%S')}] Подключен twitch! Всем привет, система работает =)")
async def on_message(msg: ChatMessage):
twitch_username = msg.user.name
# print(f'[TWITCH CHAT {msg.room.name}] {msg.user.name}: {msg.text}')
msg = {"env": "twitch", "msg": msg.text, "user": twitch_username,
"processing_timestamp": time.time_ns(), "date": eztime()}
# pre, rank, user, msg, clan, team, server, serverMode, chat_type, precision
if twitch_username in ctx.botNicknames:
print('[YT] Встречено собственное сообщение', msg["user"], 'вносим в базу', msg["msg"])
# ctx_chatOwn.append(msg)
###ctx_chatOwn = ctx_chatOwn + [msg]
# ctx.LastMineChatInteract = datetime.now()
else:
print(f"[{datetime.now().strftime('%H:%M:%S')}] [TWITCH CHAT]", msg["user"], '>',
msg["msg"])
ctx_chat.append(msg)
# this will be called whenever someone subscribes to a channel ПЛАТНАЯ ПОДПИСКА
async def on_sub(sub: ChatSub):
print(f'[TWITCH +SUB] New subscription in {sub.room.name}, type: {sub.sub_plan}, msg: {sub.sub_message}')
# this will be called whenever the !reply command is issued
async def test_command(cmd: ChatCommand):
if len(cmd.parameter) == 0:
await cmd.reply('you did not tell me what to reply with')
else:
await cmd.reply(f'{cmd.user.name}: {cmd.parameter}')
async def run_twitch_bot():
nonlocal twitch_chat
twitch = await Twitch(TWITCH_APP_ID, TWITCH_APP_SECRET)
auth = UserAuthenticator(twitch, TWITCH_USER_SCOPE)
token, refresh_token = await auth.authenticate()
await twitch.set_user_authentication(token, TWITCH_USER_SCOPE, refresh_token)
# await twitch.set_user_authentication('vi4veb8whrz6uacio4ilj9pmkrimk3', TWITCH_USER_SCOPE, ) #access token после ручного запроса
twitch_chat = await Chat(twitch)
twitch_chat.register_event(ChatEvent.READY, on_ready)
twitch_chat.register_event(ChatEvent.MESSAGE, on_message)
twitch_chat.register_event(ChatEvent.SUB, on_sub)
# you can directly register commands and their handlers, this will register the !reply command
twitch_chat.register_command('reply', test_command)
twitch_chat.start()
# ЗАКРЫТИЕ!
# chat.stop()
# await twitch.close()
# lets run our setup
# asyncio.run(run_twitch_bot())
print('НАЧИНАЕМ ЧЕКАТЬ ЮТУБ...')
def twitch_actions_executor_func():
while True:
if twitch_chat is not None:
t_act_inp = twitch_actions_queue.get()
t_act = t_act_inp.get("action", "")
try:
if t_act == "reply":
asyncio.run(twitch_chat_reply("[AI] " + t_act_inp.get("msg", "пустота")))
time.sleep(1)
# elif t_act == "ban":
# if channel_id:
# print('user', channel_id)
# tempban(liveChatId, channel_id=channel_id,
# timee=t_act_inp.get("bantime", 20))
# time.sleep(1)
# else:
# print("БАН НЕ ВЫДАН ТАК КАК НЕ СООБЩЕНО ID")
except BaseException as err:
print('TWICH action print queue err, q=', t_act)
print('ОШИБКА ВЫВОДА В ЧАТ TWITCH! ', err)
print('ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
time.sleep(1)
def run_twitch_bot_func():
asyncio.run(run_twitch_bot())
tt = threading.Thread(target=twitch_actions_executor_func, daemon=True)
tt.start()
print('LELL')
t = threading.Thread(target=run_twitch_bot_func, daemon=True)
# loop = asyncio.get_event_loop()
# loop.run_until_complete(run_twitch_bot())
#
twitch_started = False
# выше так было
# а теперь стало тк тест надо же сделать
# t.start()
# twitch_started = True
from zHyperAI_Social.TrovoClient import trovo_client_thread
print('STARTING YT CHECKER! 00')
trovo_started = False
trovo_thread = trovo_client_thread(ctx_chat, trovo_actions_queue)
print('STARTING YT CHECKER! 01')
while ctx.ThreadsActived:
if ctx.YouTubeCommentCheckerEnabled:
print('[PRE INIT YT] включил коммент чекер? вход в ветку ютуба и твича для запуска непосредственно')
if not trovo_started:
try:
trovo_thread.start()
except BaseException as err:
print('[TROVO]ОШИБКА ПОДКЛЮЧЕНИЯ! TROVO BOT! ', err)
print('[TROVO]ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
print("\n[TROVO]=== КОНЕЦ ОШИБКИ ====")
trovo_started = True
if not twitch_started:
try:
t.start()
except BaseException as err:
print('[TWITCH]ОШИБКА ПОДКЛЮЧЕНИЯ! TWITCH BOT! ', err)
print('[TWITCH]ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
print("\n[TWITCH]=== КОНЕЦ ОШИБКИ ====")
twitch_error = True
twitch_started = True
try:
print('[PRE INIT YT] COMMENT CHECHING ENABLED!!! RUNNING TWITCH BOT...')
print('[PRE INIT YT] TWITCH INIT ENDED! RUNNING YT BOT...')
response = requests.get(YoutubeStreamURL)
print(response)
streams = json.loads(response.text).get('items', [])
chat = None
VIDEO_ID = None
liveChatId = None
print('YT>> отправлен запрос к каналу ютуб...')
if (len(streams) > 0):
firstStream = streams[0]
VIDEO_ID = firstStream['id']['videoId']
print('YT>>стрим найден и подключен. ', firstStream)
chat = pytchat.LiveChat(video_id=VIDEO_ID)
StreamActived = True
youtube = CheckApp(youtube)
liveChatId = getLiveChatId(liveChatId, VIDEO_ID)
# https://github.com/taizan-hokuto/pytchat/wiki/LiveChat
else:
StreamActived = False
time.sleep(10)
print('YT>>ERR>> на канале стримов нет в данный момент')
while ctx.YouTubeCommentCheckerEnabled and StreamActived and chat is not None and chat.is_alive():
answered = False
try:
if len(ctx.YoutubeActionsQueue) > 0:
if ctx.YouTubeAppEnabled:
q = ctx.YoutubeActionsQueue[0]
youtube = CheckApp(youtube)
if liveChatId is not None and VIDEO_ID is not None:
liveChatId = getLiveChatId(liveChatId, VIDEO_ID)
if youtube is not None and liveChatId is not None:
act = q.get("action", "")
try:
if act == "reply":
sendReplyToLiveChat(liveChatId, "[AI] " + q.get("msg", "пустота"))
time.sleep(1)
elif act == "ban":
# if q.get("ytname", None) is not None:
# channel_id = getYoutubeUserId(YOUTUBE_STREAM_API_KEY,q.get("ytname"))
channel_id = q.get("youtube_user_channel_id", None)
if channel_id:
print('ban channel_id', channel_id)
tempban(liveChatId, channel_id=channel_id,
timee=q.get("bantime", 20))
time.sleep(1)
else:
print("БАН НЕ ВЫДАН ТАК КАК НЕ СООБЩЕНО ID")
except BaseException as err:
print('youtube action print queue err, q=', q)
print('ОШИБКА ВЫВОДА В ЧАТ ЮТУБА! ', err)
print('ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
time.sleep(0.2)
ctx.YoutubeActionsQueue.pop(0)
answered = True
except BaseException as err:
if not answered and len(ctx.YoutubeActionsQueue) > 0:
ctx.YoutubeActionsQueue.pop(0)
print('ОШИБКА ПОДКЛЮЧЕНИЯ! ПОХОЖЕ СТРИМ ЗАКОНЧИЛСЯ1! ', err)
print('ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
print("\n=== КОНЕЦ ОШИБКИ ====")
ctx.IsYTChatConnected = False
time.sleep(3)
try:
data = chat.get()
items = data.items
# print("lol", items,chat)
# обработка каждого сообщения в чате
for c in items:
# getYoutubeUserId(YOUTUBE_STREAM_API_KEY,c.author.name)
if c.message == "!hello lol":
ctx.YoutubeActionsQueue.append({"action": "reply", "msg": "hello" + c.author.name})
ytname = c.author.name
# print(f"YT>>{c.datetime} [{col(str(thissrank))}|{col(ytname)}] {col(c.message, 'yellow')}")
msg = {"env": "youtube", "msg": c.message, "user": ytname,
"youtube_user_channel_id": c.author.channelId,
"youtube_moderator": c.author.isChatModerator,
"processing_timestamp": time.time_ns(),
"date": eztime()}
# pre, rank, user, msg, clan, team, server, serverMode, chat_type, precision
if (msg["user"] in ctx.botNicknames):
print('[YT] Встречено собственное сообщение', msg["user"], 'вносим в базу', msg["msg"])
# ctx_chatOwn.append(msg)
###ctx_chatOwn = ctx_chatOwn + [msg]
# ctx.LastMineChatInteract = datetime.now()
else:
print(f"[{datetime.now().strftime('%H:%M:%S')}] [YOUTUBE CHAT]", msg["user"], '>',
msg["msg"])
ctx_chat.append(msg)
ctx.IsYTChatConnected = True
time.sleep(2)
except BaseException as err:
print('2ОШИБКА ПОДКЛЮЧЕНИЯ! ПОХОЖЕ СТРИМ ЗАКОНЧИЛСЯ2! ', err)
print('ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
print("\n=== КОНЕЦ ОШИБКИ ====")
ctx.IsYTChatConnected = False
time.sleep(10)
except BaseException as err:
print('1ОШИБКА ПОДКЛЮЧЕНИЯ! ПОХОЖЕ СТРИМ ЗАКОНЧИЛСЯ111! ', err)
print('ТЕКСТ ОБ***Й ОШИБКИ', traceback.format_exc())
print("\n=== КОНЕЦ ОШИБКИ ====")
ctx.IsYTChatConnected = False
time.sleep(10)
time.sleep(0.1)
Больше всего я мучился с авторизацией, т.к. только 2FA авторизованные приложения могут писать в чат на платформах...
Про STT (реализация общения через Discord)
Займёмся распознаванием речи с помощью фреймворка Nvidia NEMO. Выделим метод распознавания, где на вход можно подать байты напрямую, а не файлы, и реализуем его в удобный для работы интерфейс. Скрипт нужно обязательно запускать из Docker-части, где установлен фреймворк. Подключение к этому «распознавателю» у меня было реализовано выше.
Кодопомойка NemoSpeechTranscriber (stream_stt_inf.py, Docker-часть)
import contextlib
import io
import json
import os
import time
from argparse import ArgumentParser
from dataclasses import dataclass
import numpy as np
import soundfile
import torch
from omegaconf import open_dict
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
from nemo.collections.asr.parts.utils.streaming_utils import CacheAwareStreamingAudioBuffer
from nemo.collections.asr.parts.utils.transcribe_utils import setup_model
from nemo.utils import logging
def extract_transcriptions(hyps):
"""
The transcribed_texts returned by CTC and RNNT models are different.
This method would extract and return the text section of the hypothesis.
"""
if isinstance(hyps[0], Hypothesis):
transcriptions = []
for hyp in hyps:
transcriptions.append(hyp.text)
else:
transcriptions = hyps
return transcriptions
def calc_drop_extra_pre_encoded(asr_model, step_num, pad_and_drop_preencoded):
# for the first step there is no need to drop any tokens after the downsampling as no caching is being used
if step_num == 0 and not pad_and_drop_preencoded:
return 0
else:
return asr_model.encoder.streaming_cfg.drop_extra_pre_encoded
def perform_streaming(
asr_model, streaming_buffer, compare_vs_offline=False, debug_mode=False, pad_and_drop_preencoded=False, autocast_enabled=True
):
batch_size = len(streaming_buffer.streams_length)
if (autocast_enabled):
logging.info("AMP (autocast) enabled!\n")
autocast = torch.cuda.amp.autocast
else:
@contextlib.contextmanager
def autocast():
yield
if compare_vs_offline:
# would pass the whole audio at once through the model like offline mode in order to compare the results with the stremaing mode
# the output of the model in the offline and streaming mode should be exactly the same
with torch.inference_mode():
with autocast():
processed_signal, processed_signal_length = streaming_buffer.get_all_audios()
with torch.no_grad():
(
pred_out_offline,
transcribed_texts,
cache_last_channel_next,
cache_last_time_next,
cache_last_channel_len,
best_hyp,
) = asr_model.conformer_stream_step(
processed_signal=processed_signal,
processed_signal_length=processed_signal_length,
return_transcription=True,
)
final_offline_tran = extract_transcriptions(transcribed_texts)
logging.info(f" Final offline transcriptions: {final_offline_tran}")
else:
final_offline_tran = None
cache_last_channel, cache_last_time, cache_last_channel_len = asr_model.encoder.get_initial_cache_state(
batch_size=batch_size
)
previous_hypotheses = None
streaming_buffer_iter = iter(streaming_buffer)
pred_out_stream = None
for step_num, (chunk_audio, chunk_lengths) in enumerate(streaming_buffer_iter):
with torch.inference_mode():
with autocast():
# keep_all_outputs needs to be True for the last step of streaming when model is trained with att_context_style=regular
# otherwise the last outputs would get dropped
with torch.no_grad():
(
pred_out_stream,
transcribed_texts,
cache_last_channel,
cache_last_time,
cache_last_channel_len,
previous_hypotheses,
) = asr_model.conformer_stream_step(
processed_signal=chunk_audio,
processed_signal_length=chunk_lengths,
cache_last_channel=cache_last_channel,
cache_last_time=cache_last_time,
cache_last_channel_len=cache_last_channel_len,
keep_all_outputs=streaming_buffer.is_buffer_empty(),
previous_hypotheses=previous_hypotheses,
previous_pred_out=pred_out_stream,
drop_extra_pre_encoded=calc_drop_extra_pre_encoded(
asr_model, step_num, pad_and_drop_preencoded
),
return_transcription=True,
)
if debug_mode:
logging.info(f"Streaming transcriptions: {extract_transcriptions(transcribed_texts)}")
final_streaming_tran = extract_transcriptions(transcribed_texts)
logging.info(f"Final streaming transcriptions: {final_streaming_tran}")
if compare_vs_offline:
# calculates and report the differences between the predictions of the model in offline mode vs streaming mode
# Normally they should be exactly the same predictions for streaming models
pred_out_stream_cat = torch.cat(pred_out_stream)
pred_out_offline_cat = torch.cat(pred_out_offline)
if pred_out_stream_cat.size() == pred_out_offline_cat.size():
diff_num = torch.sum(pred_out_stream_cat != pred_out_offline_cat).cpu().numpy()
logging.info(
f"Found {diff_num} differences in the outputs of the model in streaming mode vs offline mode."
)
else:
logging.info(
f"The shape of the outputs of the model in streaming mode ({pred_out_stream_cat.size()}) is different from offline mode ({pred_out_offline_cat.size()})."
)
return final_streaming_tran, final_offline_tran
from struct import pack, unpack
import librosa
import soundfile as sf
def recover_wav(f): # на ВХОД (Union object io.BytesIO) ИЛИ (BinaryIO (это open(file,'rb+') ) выдаёт то же что и на ВХОД
wav_header = "4si4s4sihhiihh4si"
data = list(unpack(wav_header, f.read(44)))
assert data[0] == b'RIFF'
assert data[2] == b'WAVE'
assert data[3] == b'fmt '
assert data[4] == 16
assert data[-2] == b'data'
assert data[1] == data[-1] + 36
f.seek(0, 2)
filesize = f.tell()
datasize = filesize - 44
data[-1] = datasize
data[1] = datasize + 36
f.seek(0)
f.write(pack(wav_header, *data))
return f
def prepare_input_audio(f,orig_sr=48000,orig_channels=2):
recover_wav(f)
y, sr = sf.read(f, format='RAW', samplerate=orig_sr, channels=orig_channels, subtype='PCM_16',
dtype='float32') # ,subtype='FLOAT' ,dtype='float32',dtype='int16'
f.close()
y = y.transpose()
if orig_channels>1:
y = librosa.core.to_mono(y).T
y = librosa.core.resample(y, orig_sr=sr, target_sr=16000).T
return y
def prepare_input_audiofile(audio_file_path,orig_sr=48000,orig_channels=2):
fileOpen = open(audio_file_path, 'rb+')
filee = fileOpen.read()
f = io.BytesIO(filee)
fileOpen.close()
return prepare_input_audio(f,orig_sr=orig_sr,orig_channels=orig_channels)
def save_outstream_to_file(audio,out_audio_path='my_24bit_file.wav'):
sf.write(out_audio_path, audio, 16000)
# SAVING FILE
#sf.write('my_24bit_file.wav', y, 16000)
voice_recog_model_name = "stt_ru_fastconformer_hybrid_large_pc"
@dataclass
class StreamingRecogArgsConfig:
pad_and_drop_preencoded = False # would perform the caching for all steps including the first step.
compare_vs_offline = False #You may drop the '--debug_mode' and '--compare_vs_offline' to speedup the streaming evaluation.
# If compare_vs_offline is not used, then significantly larger batch_size can be used.
use_amp = True
device = "cuda" #cuda or cpu
chunk_size = 100 # The chunk_size of 100 would be 100*4*10=4000ms for a model with 4x downsampling and 10ms shift in feature extraction.
batch_size = 32
shift_size = -1 # The shift_size to be used for models trained with full context and offline models
left_chunks = 2 # The number of left chunks to be used as left context via caching for offline models
debug_mode = False
autocast_enabled = True
thisfolder = os.path.dirname(os.path.realpath(__file__))
@dataclass
class TranscriptionConfig:
model_path = f"{thisfolder}/{voice_recog_model_name}/{voice_recog_model_name}.nemo" # Path to a .nemo file
pretrained_name = voice_recog_model_name # Name of a pretrained model
cuda = -1
def model_init(args,cfg):
logging.info(f"Using local ASR model from {cfg.model_path}")
asr_model, model_name = setup_model(cfg, map_location=torch.device(args.device))
#logging.info(asr_model.encoder.streaming_cfg)
if (
args.use_amp
and torch.cuda.is_available()
and hasattr(torch.cuda, 'amp')
and hasattr(torch.cuda.amp, 'autocast')
):
logging.info(f"AMP (AUTOCAST) set to {str(args.autocast_enabled)} (in config)!\n")
else:
args.autocast_enabled = False
# configure the decoding config
decoding_cfg = asr_model.cfg.decoding
with open_dict(decoding_cfg):
decoding_cfg.strategy = "greedy"
decoding_cfg.preserve_alignments = False
if hasattr(asr_model, 'joint'): # if an RNNT model
decoding_cfg.greedy.max_symbols = 10
decoding_cfg.fused_batch_size = -1
asr_model.change_decoding_strategy(decoding_cfg)
asr_model = asr_model.to(args.device)
asr_model.eval()
# chunk_size is set automatically for models trained for streaming. For models trained for offline mode with full context, we need to pass the chunk_size explicitly.
if args.chunk_size > 0:
if args.shift_size < 0:
shift_size = args.chunk_size
else:
shift_size = args.shift_size
asr_model.encoder.setup_streaming_params(
chunk_size=args.chunk_size, left_chunks=args.left_chunks, shift_size=shift_size
)
streaming_buffer = CacheAwareStreamingAudioBuffer(
model=asr_model,
online_normalization=False,
pad_and_drop_preencoded=args.pad_and_drop_preencoded,
)
return asr_model, streaming_buffer
def model_transcribe( asr_model, streaming_buffer,args, audio_samples=None, audio_file=None, audio_settings = None):
start_time = time.time()
logging.info('PERFORMING TRANSCRIBE STREAMING STARTED!')
if audio_settings is None:
audio_settings = {"sr":48000,"channels":2}
if audio_samples is None and audio_file is None:
print('ВНИМАНИЕ!!!!! АРГУМЕНТОВ НЕТ!!! ЗАКРЫТИЕ ТРАНСКРАЙБА')
return "ВЫ ДАЛИ МНЕ ПУСТОТУ!"
if audio_file is not None:
audio_samples = prepare_input_audiofile(audio_file, audio_settings["sr"], audio_settings["channels"])
else:
audio_samples = prepare_input_audio(audio_samples, audio_settings["sr"], audio_settings["channels"])
final_streaming_tran = "НИЧЕГО НЕ РАСПОЗНАНО"
if audio_samples is not None:
streaming_buffer.reset_buffer()
processed_signal, processed_signal_length, stream_id = streaming_buffer.append_audio(audio_samples,
stream_id=-1)
final_streaming_tran_mas, _ = perform_streaming(
asr_model=asr_model,
streaming_buffer=streaming_buffer,
compare_vs_offline=args.compare_vs_offline,
pad_and_drop_preencoded=args.pad_and_drop_preencoded,
)
if len(final_streaming_tran_mas)>0:
final_streaming_tran = "".join(final_streaming_tran_mas)
end_time = time.time()
logging.info(f"The whole streaming process took: {round(end_time - start_time, 2)}s")
return final_streaming_tran
class NemoSpeechTranscriber():
"""TRANSCRIBER MAIN CLASS"""
def __init__(self):
self.args = StreamingRecogArgsConfig()
self.cfg = TranscriptionConfig()
self.asr_model = None
self.streaming_buffer = None
self.initialized = False
def check_initialization(self):
if not self.initialized:
logging.info('TRANSCRIBER init...')
start_time = time.time()
self.asr_model, self.streaming_buffer = model_init(self.args, self.cfg)
logging.info(f'TRANSCRIBER init ENDED! Time:{round(time.time() - start_time, 2)}s')
self.initialized = True
return self.initialized
def audio_transcribe(self,audio_samples=None,audio_file=None,audio_settings=None):
result = model_transcribe(audio_samples=audio_samples, audio_file=audio_file, audio_settings=audio_settings,
asr_model=self.asr_model, streaming_buffer=self.streaming_buffer, args=self.args)
return result
def main_debug():
args = StreamingRecogArgsConfig()
cfg = TranscriptionConfig()
asr_model, streaming_buffer = model_init(args, cfg)
start_time = time.time()
def debugTestAudiofiles():
audiofile_list = ["test.wav"]
settings_dict = {"sr": 48000, "channels": 1}
#audiofile_list = ["test.wav","test1.wav","test2vloger.wav","test3.wav","test4old.wav"]
for audiofile in audiofile_list:
result = model_transcribe(audio_samples=None, audio_file=audiofile, audio_settings=settings_dict,
asr_model=asr_model, streaming_buffer=streaming_buffer, args=args)
print('РЕЗУЛЬТАТ 1 ОКОНЧЕН! ТРАНСКРИПЦИЯ:',result)
debugTestAudiofiles()
end_time = time.time()
print('ВСЁ ЗАВЕРШЕНО! РЕЗУЛЬТАТ:',{round(end_time - start_time, 2)},'s')
def file_to_bytes_io(filename):
fileOpen = open(filename, 'rb+')
filee = fileOpen.read()
samples_file = io.BytesIO(filee)
fileOpen.close()
return samples_file
if __name__ == '__main__':
transcriber = NemoSpeechTranscriber()
transcriber.check_initialization()
Это всё, что-ли? Мы закончили?
Выходит, что так. По части разработки в этой статье, наверное, всё! Сам не могу в это поверить...
Подводим итоги
Итак, что мы сделали:
Более 10 000 строк бредокода (сумма всех файлов у меня в проекте, я просто перестал считать, когда сумма перевалила за 10-ку)
Изучено и использовано дофигища технологий, в том числе ML (NLP, CV, TTS...)
С момента начала разработки (17.02.2023) прошло уже более одного года и двух месяцев
Что мы получили:
Более 10 банов на различных серверах Minecraft
-
Офигенные эмоции и кучу актуальных знаний и навыков
Если бы я вернулся в прошлое на 17.02.23 и меня бы спросили, занялся бы ты этим снова, я бы твёрдо ответил — да! Оно того стоило!
Эмоции включают в себя тонны потраченных нервов автора
Интересный материал для публикации на habr!
Подтвердили теорию о том, что из г* и палок можно соорудить всё, что угодно!
Что мы получили — о том, что удалось «не совсем»
Сыроватый проект, прототип, работающий с багами и перебоями
Только 1 более-менее полноценно работающий режим игры
Странное соотношение затраченных усилий к потенциальному выхлопу
-
93 подписчика на YouTube за 15 однотипных стримов (3-6 часов) игры в SkyWars
? Я предполагал что-то такое, но не настолько, конечно, думал людей чуть посильнее удивит нейронка-стрмиер)) С другой стороны, это был прототип, который сам себя продвигал. Игра однотипная, режим один. Мало. Скучновато. Так что, если подумать, ожидаемо.
Большинство из них пришлось на определённые стримы с крупными обновлениями, где NetTyan немного поумнела, получив базу данных и контекстный анализ (+ настроение и
почтирабочую систему модерации).
Что с проектом сейчас?
Так получилось, что вот уже как несколько месяцев автор (да-да, я) не особо занимался этим проектом (не запускал те самые стримы). У этого, конечно же, есть своя история...
Угарная история. Но не для автора...
Как-то раз автор убрался в квартире, после чего запустил этот проект (нейростримершу). Причём именно Python-часть. И БАЦ:
Конечно же автор не придал этому значение и забыл про проект на недельку. Потом снова решил продолжить. Открываю, запускаю — и бац, то же самое! В общем, побежал автор хныкать на свой никчемный код и перелопачивать каждый пук в нём. В процессе этого, конечно, очень сильно пахло жареным (не от компьютера, а, скорее, от уровня напряжения пукана автора).
Не буду томить вас долгим рассказом. Попробуйте угадать причину поломки. Спойлер: вы не угадаете =)
отгадка
Вот не поверите. МИ-КРО-ФОН! Да. Отсоединил его, и всё заработало. Причём, дело было не в наличии микрофона, а в самом микрофоне! Он был очень старый, но кто бы мог подумать, что система поведёт себя подобным образом?! Именно при нагрузке и именно с этим скриптом Python всё вылетало к чертям. Возможно, с ним было парочку блускринов и в других программах, но я этому особо значения не придавал.
В общем, как-то так бывает в жизни!
Когда я всё пофиксил, прошло уже очень много времени... Конечно же, сразу запустил стрим, но мотивация уже подугасла, да и зрителей уже было совсем немного... В общем, как-то слегка приуныл. И забросил это дело ещё на месяцок-другой...
А в последний месяц-таки у автора хватило сил набраться мотивации выпустить об этом пост, ведь умом не передать, сколько во всё это дело было вложено сил, времени и души! Да, статья не идеальна, особенно код, но иначе — это всё просто бы исчезло без следа. Кажется, сейчас все мы переживаем не лёгкие времена...
Максимально благодарен каждому, кто дошёл до этого момента! Статья получилась действительно крупной. Удивительно, что кто-то вообще смог до этого момента дойти! Что вы думаете по поводу проекта, самой статьи? Стоит ли мне продолжать заниматься этим, или лучше задумать другой проект? Буду очень благодарен, если тыкните в опросе после этой статьи. Сейчас для меня это важно, так как я принимаю решение о том, идём ли мы с этим дальше, и мне важно знать, интересно ли это людям также, как было мне, и если да — то я, конечно, не остановлюсь на начатом))
Для заинтересованных
Любые вопросы, предложения, всё, что угодно — не стесняйтесь обращаться на хабр, в дискорд, в телеге (всё в ссылках снизу)
Нашли классную TTS с голосом тянки и поддержкой русского языка намного лучше, чем есть сейчас — прекрасно, скиньте мне, пожалуйста)
Хотите вместе со мной разрабатывать — пишите, я только за! Мне бы помощь очень бы не помешала, особенно касающаяся генеративного ядра! А ещё можете нафоркать мой форк альтоклефа — он есть на гитхабе, позапихивать туда, например, разных режимов, или, если вам будет скучно, пофиксить костылики))
Есть наработки с генеративками или чем-то вроде MineRL — ооо, это круто! Давайте объединим усилия!
Вам очень понравился проект?
У вас есть деньги или ненужная видюха >16G — буду очень благодарен мощной видюхе как в облаке, как и в реале) Сейчас у меня б/у 3090, она очень медлит с лламой (2 минуты на 100 токенов, ужас), потому-то я особо и не могу сильно развенуться для дообучения фредов, а уж тем более ллам...
А ещё я сейчас заканчиваю бакалавра (прикладная информатика в интерактивных медиа), если кто-то знает классные маги, связанные с ИИ и NLP в частности, в которые несложно поступить, тоже напишите, пожалуйста)
Если у вас интересный проект или стартап по смежной теме и вам нужен человек — также пишите)
В общем, по любым важным вопросам милости прошу в tg @HyperVlad
Перспективы и дальнейшие планы
На самом деле, во время разработки именно мой неугомонный мозг мешал мне большего всего. Например, проект можно было бы спокойно довести до ума и без субтитров и индикатора настроения, на которые ушло достаточно много времени. И даже после всего, у меня есть осталась огромная куча нереализованных идей!
Идеи и перспективы
К сожалению, только под конец работы я начал понимать, что идеи надо где-то складировать, потому что мой мозг оказался той ещё помойкой (ага, как и эта огромная статья, особенно фрагменты кода в ней, как и мой гит хаб, как и моя жизнь нет, я не дед-инсайд, дамы и господа, это я так шутить пытался). Изначально я просто пихал всю кучу идей в гугл док, но потом до меня дошло, что можно использовать всякие удобные сервисы для проектов по типу YouGile.
В общем, зафигачил доску с идеями, и с тех пор работа шла чуть проще, во многом благодаря размышлению над приоритетами.
Самое значимое для дальнейших перспектив проекта — решения, которые будут приняты сейчас (см. раздел выше).
P. S.
Честно скажу, если в статье выглядит, что я описываю какой‑то простой для себя процесс, на деле, на начальных этапах, когда я всё это изучал, я думал, что моя цель недостижима, а результат с учетом моих требований — невозможен. Я просто по приколу, как слепой котёнок, долбился о самые забытые края гугла, так как в общем‑то даже не был уверен, что такую систему можно собрать в принципе для русского языка и без кучи зеленых. Но, путём незаурядного упорства и многократной долбёжки о стену я потихоньку начал включаться в работу... Ведь изначально с моим не самым широким, скажем так, бэкграундом, я даже и предположить не мог, что смогу когда‑нибудь собрать нечто подобное. Надеюсь, для кого‑то эта статья сможет стать мотиватором, так как в очередной раз доказывает, что для такого «лома», как упорство, в этой жизни нет ничего невозможного! Как известно, против лома нет приёма, это было просто (нет), пользуйтесь =)
Различные отсылки в статье и их объяснение
Название статьи «Создаём свою стример‑тян из зефира и палок» — отсылка на известный в народе фразеологизм «из г‑на и палок», означающий создание чего‑то из подручных средств, и песню‑мем «Принцессы не какают, а если какают то зефиром».
Раздел «Just Python» — отсылка на мем «Just Monika» про персонажа Монику из психологического хоррора Doki Doki Literature Club.
-
Канал этого проекта: https://www.youtube.com/@NetTyan
-
В шортсах залито несколько забавных моментов со стримов
(Осторожно, имеет место быть мат или неприятные выражения)
-
-
Канал проекта в Discord: https://discord.com/invite/BQfbpV7j4k
Стал основной базой проекта, по сути. Там больше всего информации.
Канал в телеге, где я выпускаю всякие подобные штуковины: https://t.me/neuroxren
-
GitHub автора (меня): https://github.com/3ndetz
(здесь может появиться этот Python-проект, а Java-часть уже залита)
-
HuggingFace: https://huggingface.co/3ndetz
(там можно потыкать, например, решатор mc-капчи)
-
Ещё одна статья автора: https://habr.com/ru/articles/733958/
Моя первая статья на хабре про «анализатор ников» на FRED-T5
Комментарии (14)
Nikollor48
15.05.2024 13:30+5Первый человек был создан из глины. Первый сверхчеловек - из зефира. Я, как любитель зефира, одобряю.
Kristaller486
15.05.2024 13:30+6Хочется поддержать автора, это интересный проект и все дела, но как же это блин тяжело читать...
Wesha
15.05.2024 13:30+2А расскажите, каким секретным заклинанием Вы флоат-попапы делаете,
да ещё с картинками
А то в документации хабра чёрта с два найдёшь.
Squoworode
15.05.2024 13:30+9По‑японски девушка будет тян — tyan.
Умер от кринжа.
ozlik Автор
15.05.2024 13:30Кажется, это из раздела, где мы думали над ником. Tyan-таки стало частью ника и в этом моменте я так показывал процесс формирования ника, ну а ники обычно пишут английскими символами, вроде бы все логично, не?) Или вы умерли от кринжа, потому что я не вставил в статью японский иероглиф и инструкцию по его прочтению?
GennPen
15.05.2024 13:30+7Человек "умер от кринжа" скорее всего потому, что "тян" - это суффикс, который означает близость неофициальных отношений равных по социальному и возрастному уровней людей. В русском языке такой подобный аналог обычно когда друга называешь не "Александр", а "Сашка".
Squoworode
15.05.2024 13:30+7Во-первых, латиницей, по Хэпберну, это записывается как "chan". По написанию "тьян" палятся безграмотные воннаби-виабу школьницы. Впрочем, для ИИ-стримерши это вполне гармоничная часть образа :)
Во-вторых, "тян" - это не "девушка по-японски", это хонорифик. Сущность в виде
гномикасуффикса. Это как если бы японцы сказали: по русски парень будет "ович", а девушка - "овна"...То, что в русском языке оно стало обозначать девушку - это исключительно наша локальная аномалия.
Wesha
15.05.2024 13:30(задумчиво) А некоторые себе тян без вот этого вот всего делают, на собственном эмуляторе...
snakers4
15.05.2024 13:30+3В целом контент конечно получается на любителя (скучнее, чем стримы с губками бобами, или авторский контент типа Альтернативного Варкрафта), но работает норм.
Меня скорее удивляет, что всего +30 у статьи. Это же идеальная веселая треш-статья.
Раньше сильно более простые веселые проекты набирали в районе +100. Хабр видимо всё-таки прикатился.
Автору стукнул в телегу.
Travisw
Моя карьера стримера и летсплейщика завершилась на этом моменте