Создаём свою стример-тян из зефира и палок

в 12:45, , рубрики: Без рубрики

Немного опоздал с трендом, но не пропадать же добру просто так. Может, кому-нибудь пригодится (например, для того, чтобы посмеяться или кринжануть с человека, который год своей жизни потратил на что-то вроде этого).

Наверняка вы слышали о нашумевшей в своё время ИИ стримерше NeuroSama. Меня тогда очень заинтересовала эта тема, и я решил во что бы то ни стало повторить задумку автора, несмотря на то, что некоторые моменты стримов вызывали у меня кое‑какие Насчет того, что ситуация на стриме была подстроена, а диалоги прописаны заранее, чтобы создавать ситуации, которую привлекут максимум внимания и создадут своеобразный клик бейт для удержания зрителей, как это часто бывает стриминговых платформах. Здесь хочу уточнить, что речь идёт не об общении в чате (хотя и там это тоже возможно), <strong>а о будто бы "связи" генеративного чат-бота и игрового скрипта.</strong></p>" data-abbr="сомнения">сомнения.

Мое внимание привлекло не само шоу и все эти нашумевшие самые «крутейшие» моменты стримов, а сам факт того, что нейросеть реально может полностью автономно и полноценно вести стрим, удерживая внимание зрителей!

В этой статье я расскажу о попытке создать свою нейро‑тян для русского сегмента, которая сможет автономно и без перерывов играть и вести трансляции на различных стриминг‑платформах и буллить кожаных мешков конечно же развлекать зрителей и игроков, Или хотя бы, по крайней мере, не получая их слишком быстро</p>" data-abbr="не получая баны">не получая баны!

Статья получилась без преувеличения огромной из‑за совмещения просто ТУЧИ разных технологий и необходимости погружения в тонкости некоторых, поэтому запасайтесь бочкой кваса и ванной попкрона, как и в прошлый раз, приключение обещает быть жарким, но не только потому, что скоро лето, а ещё потому, что сейчас весна (и сопутствующее весеннее обострение), ведь мы с вами будем создавать настоящую (виртуальную) девушку‑стримера!

Спойлер: будет весело, иногда сложно и очень интересно как опытному бойцу, так и простому обывателю!

Если не хотите читать всё — воспользуйтесь спойлером «портал».

Портал (в самые интересные моменты)

Этот раздел — что-то вроде содержания, но не совсем, нет четкой структуры. Отсюда вы сможете быстро пройтись по интересующим разделам статьи! Для этого просто жмакаем на подсвеченные ссылки (произойдёт магия: переход к якорю — нужному разделу)!

Создаём свою стример-тян из зефира и палок - 1

⚠️Если вы собираетесь прыгнуть сразу в конец статьи или какую-то её часть, обязательно ознакомьтесь с дисклеймерами!

Это — не содержание. Это — список ссылок на некоторые значимые моменты.

Какие технологии будут использоваться (спойлеры)

Нейросети: 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 — «жервтовать») — добровольные пожертвования. Ради этих самых донатов, а ещё для роста метрик популярности трансляций (лайков, просмотров и пр.) некоторые стримеры и стримерши способны творить всякую дичь, вплоть до раздевания перед камерой или уничтожения своего имущества. Стримеры, как правило, развлекают аудиторию с помощью игр, просмотров видосиков и прочего. Стримерши — аналогично, только у них на вооружении есть, кхм, как бы это сказать, некоторые дополнительные «аргументы»...

  • </p>" data-image="https://habrastorage.org/getpro/habr/upload_files/85f/295/7dc/85f2957dcb356a4fe2e1b974fbe26c03.jpg" data-abbr="Тян" data-image-width="736" data-image-height="1102">Тян (тянка, tyan) — ред. Именно в русском языке "тян" превратилось в девушку, но в Японском это не совсем девушка, на самом деле, а скорее суффикс. Тян, кун, сан, сама - японские именные суффиксы. "Тян" используют при обращении к человеку, который тебя младше. Чаще всего "тян" добавляют при обращении к маленькой девочке.<em>&nbsp;Как правило, суффикс не используют в мужском обществе и при обращении к мужчинам.</em></p>" data-abbr="девушка? Не совсем">девушка в переводе с японского. Понятие стало молодёжным, так как суффикс «тян» активно использовался в японских мультипликациях – Японские мультики для любых возрастов, характеризующиеся повышенным вниманием к персонажам и их эмоциям.</p><p>На картинке&nbsp; момент из серии аниме "Судьба/ночь схватки"</p>" data-image="https://habrastorage.org/getpro/habr/upload_files/a13/384/831/a1338483130d49ab0ad77c31ebb7f544.gif" data-abbr="аниме" data-image-width="540" data-image-height="304">аниме. Вообще, с аниме связано огромное количество трендов современной молодёжи, начиная от коллекционирования различных значков и заканчивая самым настоящим аниме‑сектантством (шутка). На фото люди, переодевшиеся в персонажей организации "Акатсуки" из аниме "Наруто"</p>" data-image="https://habrastorage.org/getpro/habr/upload_files/091/a00/8d7/091a008d75d2270b751dc3853ee13d03.jpg" data-abbr="Анимешники" data-image-width="604" data-image-height="453">Анимешники — особо ярые знатоки аниме, несмотря на свою безвредность, во время своего зарождения активно подвергались буллингу со стороны особо консервативных малолетних неанимешников, многие из которых впоследствии тоже стали анимешниками. В общем, это всё очень интересная тема, кому интересно, можете продолжить поиски в гугле.

  • Многие (в том числе я, потому что так привычнее) пишут докиматура, но правильно будет "дакимакура".</p>" data-image="https://habrastorage.org/getpro/habr/upload_files/ec3/e31/e07/ec3e31e070223e7b0536df4958c5597e.jpg" data-abbr="Докиматура" data-image-width="1200" data-image-height="800">Дакимакура — подушка с изображением или любого другого персонажа</p>" data-abbr="тянки">тянки из аниме. Обязательный атрибут любого уважающего себя анимешника (шутка).

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

Постановка задачи

До начала проектирования общего прототипа системы я сразу определил несколько требований:

  • Чтобы нейросеть могла во что‑то играть. Необязательно сама нейросеть, но геймплей должен быть полностью автоматическим.

  • Чтобы эта нейросеть могла взаимодействовать с игрой, как минимум, с игровым чатом и пользователями путём общения. Игра также должна быть многопользовательской.

  • Стримерша должна иметь В контексте стриминговых платформ виртуальным аватаром обычно называют видеопоток, наложенный поверх трансляции вместо веб-камеры. Обычно люди используют виртуальные аватары в паре с вебкой, чтобы трекать свои фейсы на модель аватара и быть особо "модными".</p><p>Если не поняли, не бойтесь, в конце будет наглядный пример =)</p>" data-abbr="виртуальный аватар">виртуальный аватар, любым образом реагирующий на действия в игре (нам нужна живая 2д тян, а не дакимакура).

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

  • Стримерша должна иметь возможность коммуникации в первую очередь на русском языке.

  • Коммуникация должна происходить в нескольких видах: в устном (синтез и распознавание речи) и письменном (текстовые сообщения в игре, в чате на Ютубе, Твитче и т. д.)

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

Резюмируя вышесказанное, наша (или, если я вам не нравлюсь, моя) цель — сварганить ИИ стримершу с виртуальным аватаром, которая будет без перерывов играть в игру, одновременно отвечать на вопросы зрителей и игроков, развлекать их и подкалывать токсиков! Система должна быть интересной и «весёлой», а также знать, когда нужно «остановиться» в своём «веселье», чтобы не получить блокировку.

Пайплайн дальнейшей работы выстроим следующим образом:

  1. Определим стек технологий. Превратим цель в задачи. Спроектируем общую программную архитектуру.

  2. Разработаем автоматическую игровую программу (игровой бот).

  3. Разработаем вспомогательные системы.

  4. Свяжем системы между собой.

  5. Развернём систему и проведём тестовый стрим.

  6. Проанализируем результаты и доработаем систему.

  7. Будем повторять пункты 5–6 до тех пор, пока не будет достигнуто удовлетворительное качество работы.

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

Выбор технологий

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

Игра

Minecraft. Ну этот выбор даже объяснять не нужно, он очевиден.

Для тех, кому нужно объяснение
Эта атмосфера...

Эта атмосфера...

Для всех потерянных сфероидов обделенных квадратностью людей напоминаю, что майнкрафт — это не просто игра, это жизнь, которая дарит многочисленным пользователям смысл к существованию в период депрессии, который у них наступает сразу после возвращения с уроков конечно же работы с зп 999к $/наносек.

Автоматический игровой агент

Для сборки первого прототипа нейростримерши я не хотел заморачиваться над ИИ игроком — мне достаточно было того, чтобы был скрипт, который сам мог бы играть на каких‑либо многопользовательских серверах, поэтому я решил просто забабахать с помощью какой‑нибудь не самой сложной системы пару функций для игры с людьми (например, в миниигре SkyWars боту достаточно будет собирать ресурсы</p>" data-abbr="лутать ресы">лутать ресы и убивать игроков, получать и отправлять сообщения в чат).

Размышления о MineRL

Чтобы позволять нейросети ориентироваться и управлять игрой, разработчик нейростримерши NeuroSama, предположительно, использовал MineRL, для которой характерно подергивание мыши, как мы видим на некоторых стримах.

Момент со стрима NeuroSama

Момент со стрима NeuroSama

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

Кому интересна тема визуального управления ИИ в Minecraft, можете поглядеть MineDojo. Из последних разработок интерес привлекает VOYAGER (к сожалению, не тот самый космический аппарат, а реализация агента управления игрой Minecraft через ChatGPT и бота MineFlayer JS), я же пойду путём попроще — просто использую мод на Minecraft, который позволит управлять игрой при помощи команд.

Визуализация маршрута, построенного Baritone в Minecraft

Визуализация маршрута, построенного Baritone в Minecraft

Очень повезло, что на глаза попался AltoClef — мод для Minecraft (Инструментарий для разработки модов на Java-версию игры Minecraft.</p>" data-abbr="Fabric">Fabric), который может полностью автоматически пройти игру с помощью своей иерархической системы тасков (задач). Например, таск «пройти игру» включает в себя задачу «добыть дерева»; задача «добыть дерева» раскладывается на задачи «найти дерево», «поломать дерево» и т. д. Предполагалось с помощью AltoClef замутить аналогичную задачу «выиграть в SkyWars», в которую будет входить убийство игроков, лутинг сундуков и т. п. Движение в AltoClef реализовано при помощи старого доброго «навигатора» для майнкрафт — Baritone (многие заблуждаются, что это ИИ, однако там рулит обычный алгоритм, основанный на эвристиках).

Так как моды для Minecraft обычно пишут на Java, хорошо бы заранее подумать о связи мода с центральным скриптом, который явно будет на Python (далее раскрою почему). И тут нам на помощь приходит старый добрый друг гугл, в котором по соответствующему запросу сразу же выплывает инструмент для связи Java и Python с говорящим названием Py4j, который вполне позволяет сделать внутренний сетевой мост между модом на Java и скриптом на Python.

Синтез речи

Если кратко: нам нужен голос тянки. Так как я описываю свои раздумия спустя много времени, на тот момент в результате поиска по тг каналам актуальным Text-to-speech, технология синтеза речи</p>" data-abbr="TTS">TTS был Silero, а для роли тянки у силеро имелся замечательный голос Baya. В рамках его лицензии CC BY‑NC мы могли бы использовать этот голос хотя бы для прототипа, а уже потом при развитии проекта его можно было бы заменить на другой, благо индустрия не стоит на месте и к тому моменту мы могли бы увидеть более открытые, доступные и качественные ТТСки с более подходящими голосами и лицензиями.

Кому интересна данная тема, можете также посмотреть Bark (офигенный, но тяжелый и медленный) и топовые наработки TeraTTS (очень классно, особенно голос GLaDOS, но хочется больше [голосов]).

Виртуальный аватар

В качестве ПО для виртуального аватара решено было использовать VTube Studio из‑за возможности удобного подключения к API системы через Python в дальнейшем.

Изображение выбранной модели Live2D в Steam

Изображение выбранной модели Live2D в Steam

Для самого аватара была выбрана Live2D модель «LiveroiD_A‑Y01» от японского разработчика. На сайте публикации модели было указано, что автор разрешает бесплатное использование её для видео и прямых трансляций. Модель также доступна в Steam. Опять же, при развитии проекта и появления стойкого образа персонажа можно будет сделать рестайлинг и выпустить собственную модель.

Примечательно, что популярная нейростримерша NeuroSama изначально функционировала на стандартной встроенной в VTube Studio модели «Одна из встроенных в VTubeStudio моделей</p>" data-image="https://habrastorage.org/getpro/habr/upload_files/569/5b3/df5/5695b3df54758792dd1b3723ad11c5e9.jpg" data-abbr="Hiyori" data-image-width="1280" data-image-height="720">Hiyori Momose» и никто автора за это особо не критиковал =)

LLM

В качестве LLM, Large Language Model, большая языковая модель – текстовая генеративная нейросеть</p><p></p>" data-abbr="ллмки">ЛЛМки я решил использовать мой ранее рассмотренный FRED‑T5. С того времени появилось много крутых файн‑тюнов модели, наиболее классными мне показались инструкт‑фреды от SiberianSoft. Отдельное внимание здесь хочу уделить Денчику, эксперту по фредам и вообще ллм в целом, который очень помог мне разобраться, огромное и человеческое ему спасибо!

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

Где GPT-4? Почему не OpenAI? Почему не Лама? Что за фред такой?

На момент создания нейростримерши (а это было давно) автор (да‑да, я) был не достаточно удовлетворен модерацией от API OpenAI. Дело в том, что в майнкрафте достаточно много токсиков, а нейросеть на тот момент, скажем так, «скучненько» отрабатывала токсичные запросы, а ведь именно на API OpenAI я пытался вначале строить весь пайплайн. Кроме того, я не абы‑кто, а великий студент, живущий в съемной однушке, не моё это царское дело тратить какие‑то сущие доллары на токены GPT-4 (тогда вообще была только GPT-3.5) API и покупать 10xH100 для Процесс исполнения обученных моделей машинного обучения для получения предсказаний на данных, поданных на вход модели </p>" data-abbr="инференс">инференса ламы 9999b...

Конечно, Llama, mistral&amp;mixtral, ChatGPT Популярные большие языковые модели ИИ</p>" data-abbr="ламы, мистрали, чатжпт ">ламы, мистрали, чатжпт (и пр.) явно будут поумнее, чем Фред, однако мы ведь делаем не сверхумный чат‑бот, знающий всё обо всём. Для прототипа нам бы сгодилась весёлая тролль‑балаболка чуть лучше ранних версий Яндекс Алисы, которая будет необязательно понимать, что происходит, но поддерживать диалог и временами выдавать какие‑нибудь весёлые и иногда странные фразочки, а Фред, как мы убедились, даже без fine-tune, дообучение языковой модели на специализированную задачу</p>" data-abbr="файнтюна">файнтюна это умеет очень круто! Кроме того, Фред выдавал и «эмоции» по заданной мной инструкции, поэтому давайте попробуем поработать именно с ним.

Тем не менее, если в будущем этот проект вдруг станет популярным, естественно, я сменю Фреда на более крутую и умную модель и задействую больше ресурсов, чем сейчас. На случай, если в будущем фред вдруг эволюционировал, стал самым сильным ИИ, захватил мир и читает эту статью: дружище, я пошутил, никто тебя менять не собирался, это была всего лишь ШУТКА:)

Предварительная модерация сообщений (антибан)

А как вы хотели? Если вы читали мою прошлую статью, вы, наверное, понимаете, что Фред может сказануть что‑то «очень не очень», поэтому тут без вариантов нужна модерация. Перебор списком слов? Нее, слишком скучно (можно добавить как дополнительный этап, но точно не как основной). Конечно же сделаем по максимуму: зарубим классификацию по токсичности фраз как пользователей, так и нейросети, а ещё, чтобы было веселее, будем закидывать это как‑нибудь в prompt – входные данные для генеративной нейросети</p>" data-abbr="промпт">промпт, чтобы нейронка получала инфу про токсика в виде чего‑то такого: «Данный пользователь рассуждает на тему расизма и использует вульгарную лексику». Классификаторов есть уйма, например, от s‑nlp или apanc. Большинство распространяются под лицензией <strong>Creative Commons</strong></p>" data-abbr="CC">CC BY‑NC‑SA, на первое время (для прототипа) пойдёт, а, если разрастётся, несложно и свой написать или, может, к тому времени уже что‑то более открытое выкатят.

Центральный скрипт управления

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

Как я уже заметил ранее, разумно будет писать центральный скрипт на Python. К Ютубу обращаемся через Google YouTube Data API, с модом в игре у нас мост Py4j, с OBS (стриминговая программа) и VTube Studio можно связаться через WebSocket API. Для хранения контекста диалога и данных о пользователях будем юзать БД (базу данных) sqlite и встроенный интерфейс работы с ней в Python.

Так как имеем дело со множеством высоконагруженных отдельных узлов, будем использовать мультипроцессорную архитектуру Python (просто напишем скрипт для связи с каждой из частей и погрузим каждый скрипт в отдельный процесс), и свяжем эти процессы через общие очереди (multiprocessing.queue) и пространства имён (multiprocessing.namespace) и будем передавать их как аргументы в каждый процесс при старте. По большей части асинхронное программирование реализовано не будет, потому что автор создаёт прототип и главная цель — чтобы оно вообще всё как‑то работало, а улучшать уже можно будет потом. Мультипроцессинг здесь — костыльное и неоптимизированное решение, но сгодящееся для прототипа.

В главный скрипт, кроме спавнера вспомогательных процессов, также должны войти системы выбора комментариев (не на все же сообщения отвечать), что‑то вроде <strong>Retrieval</strong>-<strong>Augmented</strong>&nbsp;<strong>Generation</strong> </p>" data-abbr="RAG">RAG (связанная с База данных</p>" data-abbr="БД">БД система, которая будет дополнять промпт в зависимости от инфы о пользователе и других данных) и система получения и отправки сообщений для Ютуба и Твитча (чем больше поддерживаемых платформ — тем лучше).

На этом этапе я выкатил что‑то вроде схемы стека технологий, чтобы сформировать общее представление того, что мы делаем.

Схема связей системы для общего представления

Схема связей системы для общего представления

Так, всё! Устали мы от нудной теории! Перейдём же, наконец, к практике! Начнём, пожалуй, с игровой части, для меня это самое сложное... (естественно, лучше начинать с простого, но в этом случае я не знал, смогу ли вообще это сделать, поэтому и начал с самого сложного для меня)

Хардкод на Java с нуля: поехали 😎

Сразу скажу, что Код в кавычках означает говнокод, шизокод и вообще всё самое ужасное, что можно отнести к коду</p>" data-abbr=""код"">«код», который будет представлен далее, по большей части для прототипирования. Не стоит его оценивать или считать за образец, он может быть полезен только тем, кому будет интересно повторить мой опыт, а не для «искателей чужих ошибок»)) В коде вы можете увидеть огромные закоментированные свалки, не обращайте внимания, т. к. у меня был выбор либо публиковать код, либо нет. Я никак не форматировал его и не подготавливал к выводу «в свет» и потому не стеснялся оставлять там костыли и другие неприятные вещи, например, принты для отладка</p>" data-abbr="дебага">дебага. Однако кое‑что я всё‑таки форматнул, но эту особенность заметят только самые внимательные =)

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

«Кодопомойка+» будет означать, что к каким‑то функциям я заботливо добавил парочку комментариев.

Про GitHub...

Ээээ.... Что же мне сказать по поводу GitHub... Вернее... Про публикацию моего всего текущего кода на нём... Как бы по-мягче... Сразу скажу, кое-что таки да я выгрузил.

Скажем так. Если эта статья делалась около месяца — то, если бы я выпускал полный проект на GitHub — код бы там вышел через 10 лет, а статья — никогда. Ну камон... Мой разрозненный бредокод — немного не то, что принято выкладывать на такие площадки, как GitHub. В то же время, мне очень хотелось этим всем поделиться, не прикладывая ещё более титанических усилий и не нарушая мой «тунеядский» ритм работы... Для GitHub, по-хорошему, нужна четкая структура проекта, а я не хотел заморачиваться, так как у меня часть системы на Docker и проект PyСharm на винде, в которых удобно разрабатывать и дорабатывать, а виртуальная стримерша — это такая штука, которую постоянно надо дорабатывать. Несмотря на месяцы работы, я пока даже близко не подошёл к моменту, когда можно «закончить» разработку какой‑то системы и выдать «релиз». По крайней мере, мне так кажется... Но, в будущем, быть может, я захочу привести проект к более‑менее «публикационному» для GitHub виду (например, запихаю всё на Docker).

Да, можно было бы выгрузить некоторые обособленные модули — например, тот же решатор капч, или текущую версию моего форка AltoClef'a. Вот это — уже совсем другой разговор, и для портфолио может быть полезно! Они уже лежат на моём гите, вот только от этого вряд ли поменяется тот факт, что там всё ещё свалко-безобразный код...

Дорабатываем мод для игры

Больше всего я сомневался насчет своих навыков по части разработки Java (их не было), а из Объектно-ориентированное программирование</p>" data-abbr="ООП">ООП я серьезно мутил что‑то только на C#, поэтому первым делом решено было допилить нужный мне мод для Minecraft! Как мы выше условились, за мод берём AltoClef. Открывам IntelliJ Idea Community, скачиваем репу и поехали в путь‑дорогу. Ну или поползли, в моем случае... После недели маянья с настройкой и запуска в gradle (как никак у человека опыта в Джаве примерно ноль), кое‑как я добился сборки и запуска проекта по исходникам.

Код и всё такое (Java)

Заходим в гости к нашему проекту AltoClef в IntelliJ. Смотрим, как тут всё устроено.

Классы с задачами в AltoClef

Классы с задачами в AltoClef

Как видим, тут у нас реализована 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));
    }
}

Запустим callback – обратный вызов</p>" data-abbr="каллбек">каллбек и точку входа из класса инициализации мода (я вставил только строчки кода с инициализацией).

Кодопомойка фрагмента 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 методами и конструкции вида if-else и их аналоги</p>" data-abbr="чеками ">чеками для того, чтобы выстрелить от анг. event – событие</p>" data-abbr="эвенты">ивенты о написании сообщений в 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 золотые яблоки дают буст к общему количеству здоровья и ускоряют его регенерацию</p>" data-abbr="золотые яблоки">золотые яблоки и Средство телепортации в Minecraft. Работает по принципу "бросил – телепортировался".</p>" data-abbr="эндер-жемчуги">эндер‑жемчуги.

Использование золотого яблока и эндер-жемчуга для нападения

Использование золотого яблока и эндер‑жемчуга для нападения

Наконец, сочетание разнообразных навыков и скорость реакции бота на алгоритмах делают своё дело — бот способен побеждать в бою реальных людей и собирать с них ресурсы!

Победа над игроком и сбор выпавших ресурсов

Победа над игроком и сбор выпавших ресурсов

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

Стрельба из лука

Стрельба из лука

Итак, теперь, имея рабочий прототип бота, который может полностью сам играть и даже выигрывать (около 5 случаев побед на 100 игр [истерический смех]) в режиме SkyWars Minecraft хотя бы на одном из немодерируемых серверов, мы можем переходить к виртуальному аватару и связующему звену между Minecraft и Python‑скриптом.

Важно упомянуть, что для захода на сервер Minecraft, тем более немодерируемый, из‑за большого количества ботоводов на нём (вредоносных, а не развлекательных, как у нас) необходимо было вводить капчу. К сожалению, на момент написания статьи мое решение уже неактуально т. к. там сменили обычную капчу в виде Капча в виде карты Minecraft</p>" data-image="https://habrastorage.org/getpro/habr/upload_files/88a/439/6ea/88a4396ea8a1c992a1229fb56f683024.png" data-abbr="карты" data-image-width="973" data-image-height="878">карты на очень тяжелую анимированную, тем не менее это может быть интересно в качестве опыта, как я решал этот кейс на тот момент имея по сути 0 знаний в картинковом DL и Tensorflow. В спойлере ниже я раскрою детали того, как дообучал Tensorflow OCR модель распознавать майнкрафт‑капчу. А ещё я зачем-то аплоаднул это дело на хг спейс и github, так что можете сами потыкать.

MinecraftMapCaptchaSolver.useless

Итак, чтобы собрать механизм решения капчи, нам нужно:

  1. Собрать датасет из минимум 1000 капч

  2. Обучить/дообучить модель распознавания текста/картинок на них

  3. Внедрить в игру (развёртывание)

Мутим сборщик датасета (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, делаем простенькие настройки в самой программе — и вуаля, наша кошко‑девочка уже анимирована и даже виляет хвостом!

Гифка хвостатой
Стандартная анимация "Idle"

Стандартная анимация «Idle»

Фон поставил зелёный, чтобы потом в 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("nnX =",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.

Рывки также происходят в моменты резких падений или перемещений между мирами в майнкрафте.

Рывки глаз персонажа при резких движениях в игре

Рывки глаз персонажа при резких движениях в игре

Также, иногда, в ситуациях когда алгоритм не знает, что делать, наша виртуальная подруга может «закатывать глаза». И нет, это не монтаж, она реально сама так делает! Технически это происходит из-за того, что она видит цель, например, другого игрока, но на её острове нет ресурсов, с помощью которых можно было бы добраться до него, поэтому она стоит и ждёт, а разность углов между "целью" и взглядом в игре направляет взгляд вверх</p>" data-abbr="Объяснение">Почему?

Взгляд называется "вот бы мне новенькую H100"

Взгляд называется «вот бы мне новенькую Отсылка на топовую видеокарту для машинного обучения Nvidia H100</p>" data-abbr="H100">H100»

Варим кисель TyanGPT

Итак, вот мы и подошли к самому интересному! Будем делать нашей нейростримерше рот, клюв язык мозг в общем, называйте это, как хотите, а речь сейчас пойдёт о диалоговой системе.

Поминаем старый добрый анализатор ников

Итак, если вы ещё не видели мою предыдущую статью, самое время глянуть. Даже несмотря на то, что сейчас вышло множество более крутых нейросетей, я не замечал ни у одной из них такого же уровня душевности, как у Фреда... Да, он не самый умный, потому будет тяжело, но мы ведь не лыком шиты, справимся! А может, даже, без дообучения!

Душевный анализ ника от Фреда

Душевный анализ ника от Фреда

Первое, что я сделал — это допилил функцию анализа ника. Зачем изобретать велосипед и делать какое‑то особое приветствие, раз у нас уже есть простенький, но весёлый анализатор ников? Его и будем использовать каждый раз при встрече нового пользователя! Предлагаю не тянуть кота за яйца и сразу попробовать зашвырнуть наш пока ещё не оптимизированный анализатор в игру и посмотреть на реакцию игроков. Модерацию, связь с аватаром и озвучку я буду пилить далее по ходу статьи, а сейчас хочется посмотреть, как игроки отреагируют на автоматический чат‑бот прямо во время игры!

Временное соединение игры с анализатором ников (без кода)

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

Итак, берём код из прошлой статьи для анализатора ников, пихаем его в скрипт связи с майном (перейти) прямо в метод onChatMessage пока что (это временно, для теста). Делаем небольшую задержку и получаем что‑то вроде автоматического чат‑бота! По окончании генерации не забываем добавить сгенерированные сообщения в очередь написания на чат в игре.

Ну, в общем-то, всё. Для теста сойдёт. Окончательно доработаем, когда будет чат-бот.

ПРЕДУПРЕЖДАЮ:

Далее в статье вы можете увидеть элементы, которые кому‑то могут показаться не только не смешными, но и вредными, непристойными, оскорбительными. Автор ни в коем случае не пытается оскорбить обладателей любых имен, псевдонимов или группы людей, объединенных каким‑либо общим признаком. Рейтинг статьи ДАЛЕЕ строго 18+, так что лучше уберите детей от экранов!

В то же время, делать идеального, мегатолерантного и сверхкорректного робота не входит в наши (мои) планы. Ещё раз подчеркну, что мы делаем весёлую, местами глуповатую, но душевную тяночку, которая, пусть и будет нести чушь, но будет чуть‑чуть логичной, а главное — неожиданной! Разве прикольно разговаривать с чат‑ботом, который всегда даёт один и тот же «общественно верный» ответ? Отчасти да, если это ChatGPT, который нужен нам для помощи в реальных задачах, но для развлечения — такое, как по мне.

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

Так, надеюсь, не забанят, поехали дальше!

Выбор имени: это, конечно, следовало сделать ЧУТЬ раньше...

К слову, а мы ведь даже не придумали нашей тяночке название? А пора бы!

Придумываем название нашему хвостатому киборгу

Первым делом что? Cамолёты? А вот и нет! Конечно же Chat G P T! (пробелы — интонационная изюминка автора, не трогаем)

— Привет друг, какое бы ты название посоветовал для системы автоматического проведения прямых трансляций на русском языке, говорящую женским голосом и использующую модель Live2D в качестве виртуального аватара?

— Привет! Вот несколько вариантов названий для такой системы: Princess AI, NastyaVibe, StreamGirl.

(там было больше вариантов, но это я спрашивал около года назад и не со своего аккаунта, поэтому цитатой и написал только то, что запомнил)

Princess AI? Хм, что‑то знакомое... NastyaVibe? Вххахахахахах, ну тогда лучше уж сразу «DevushkaLegkogoPovedeniyaVibe» (это стереотипная шутка, Настюхи, не обижаемся, привыкайте уже, камон, тема стёба величайших имён Никит, Владов и Насть ВЕЧНА, я сам Vladick)

В общем, как вы поняли, творчество ChatGPT меня в очередной раз повеселило, но не очень помогло, ну и я решил не париться. Что мы делаем? Виртуальную девушку. Сейчас (как и 10 лет назад) в моде аниме. Аниме — японские мультики. По‑японски девушка будет тян — tyan ред. Именно в русском языке "тян" превратилось в девушку, но в Японском это не совсем девушка, на самом деле, а скорее суффикс. Тян, кун, сан, сама - японские именные суффиксы. "Тян" используют при обращении к человеку, который тебя младше. Чаще всего "тян" добавляют при обращении к маленькой девочке.<em>&nbsp;Как правило, суффикс не используют в мужском обществе и при обращении к мужчинам.</em></p>" data-abbr="(почти)">(почти). Но у нас ведь виртуальная девушка, а девушки — реальные, значит она не настоящая девушка — 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, мы всё проясним.

Кодим диалоговую систему

Что ж, теперь, когда мы знаем, что делать, приступим к коду! Проспойлерю, что по итогу получилось, конечно, сложновато для «идеального решения», которое, как говорят, (<em>к слову, у кодеров я </em><strong><em>никогда</em></strong><em> такого не видел, ведь, как мы знаем, в программировании обычно именно сложные решения работают хорошо, потому что они становятся сложными как раз после триллионов <s>пыток</s> попыток тестов</em>)</p>" data-abbr="должно быть простым">должно быть простым, но в качестве тестового прототипа сгодится.

Кодим систему фильтрации

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

Кодим систему модерации

Итак, мы решили использовать многоуровневые фильтры, что предполагает несколько нейросетевых моделей-классификаторов. Пока что внедрим 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 + 'rnxa0«»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 = '33[95m'
    OKBLUE = '33[94m'
    OKCYAN = '33[96m'
    OKGREEN = '33[92m'
    WARNING = '33[93m'
    FAIL = '33[91m'
    ENDC = '33[0m'
    BOLD = '33[1m'
    UNDERLINE = '33[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)

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

Колонки с бинарным определением токсичности (0 – токсичен) и нежелательных тем в случайном отрывке таблицы диалогов из БД

Колонки с бинарным определением токсичности (0 – токсичен) и нежелательных тем в случайном отрывке таблицы диалогов из БД

Кодим основую часть диалоговой системы

Теперь кодим саму диалоговую систему. Ну, как бы, да. Просто кодим. Больше добавить нечего. Почти. К слову: в спойлере свалка. Прям лютая. А как иначе, вы блин её схему видели? Так вот, эта схема только одного из компонентов – ответа на сообщения людей, а там ещё 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"""nn#как дела# {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 = '33[95m'
    OKBLUE = '33[94m'
    OKCYAN = '33[96m'
    OKGREEN = '33[92m'
    WARNING = '33[93m'
    FAIL = '33[91m'
    ENDC = '33[0m'
    BOLD = '33[1m'
    UNDERLINE = '33[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"))

Ну что, кодеры мои, не накодились мы ещё? А ведь мы сделали только генератор сообщений, нам к этому ещё нужно подвязать базу данных, интерфейс управления с ней, связать процесс майна и нашу генеративку и я уже не говорю о ТТСке и прочих вещах!

База данных и интерфейс работы с ней

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

ER-диаграмма базы данных

ER-диаграмма базы данных

[саркастические хлопки] Да-да, знаю, классно я составил схему — сначала уже сделал БД, 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 = '33[95m'
    OKBLUE = '33[94m'
    OKCYAN = '33[96m'
    OKGREEN = '33[92m'
    WARNING = '33[93m'
    FAIL = '33[91m'
    ENDC = '33[0m'
    BOLD = '33[1m'
    UNDERLINE = '33[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('nn') != -1):
          reply = reply.replace('nn', '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 = """nr|!'=#".,-/\&^%$#@{}[]()*"""
    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-часть (с Фредом) тогда крашнулась, и этот парнёк остался без навсегда без ответа...

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

Баги (тоже весело)
Пишет весь диалог из промпта в генерацию

Пишет весь диалог из промпта в генерацию
Нейросеть пробует себя в роли доктора (платной клиники)?

Нейросеть пробует себя в роли доктора (платной клиники)?
Повторяющиеся буквы в конце... Видимо нейросеть выдала что-то статистически закономерное после слова «чо»

Повторяющиеся буквы в конце... Видимо нейросеть выдала что-то статистически закономерное после слова «чо»

Продолжаем отдыхать от кода: вот, держите ещё пару фрагментов, теперь уже когда наша тянка стала «поувереннее» в плане автомодерации.

Тотальный разнос чата

Скриншоты работы более поздней версии NetTyan. Здесь уже нейросеть начинает умеренно «пофильтровывать» свой «нейроный язык»...

Действительно, какой милый мальчик...

Действительно, какой милый мальчик...
Отработка токсичных комментариев
«Я не ***, я просто очень умная и эрудированная девушка» © ИИ

«Я не ***, я просто очень умная и эрудированная девушка» © ИИ
"Белоснежка и 7 майнкрафтеров"

«Белоснежка и 7 майнкрафтеров»
«Если же вы готовы, то я буду бить вас с удовольствием ♥» © ИИ

«Если же вы готовы, то я буду бить вас с удовольствием » © ИИ
Ошиблись, не милый пёсик, бывает

Ошиблись, не милый пёсик, бывает
"А вы не боитесь, что мир может захватить вас?"

«А вы не боитесь, что мир может захватить вас?»

Добиваем остатки (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'bd+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)

Больше всего я мучился с авторизацией, т.к. только Двухфакторная аунтефикация</p>" data-abbr="2FA">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 с голосом тянки и поддержкой русского языка намного лучше, чем есть сейчас — прекрасно, скиньте мне, пожалуйста)

Хотите вместе со мной разрабатывать — пишите, я только за! Мне бы помощь очень бы не помешала, особенно касающаяся генеративного ядра! А ещё можете нафоркать мой форк альтоклефа — он есть на гитхабе, позапихивать туда, например, разных режимов, или, если вам будет скучно, пофиксить я добавил знакомые костылики в раздел Issues на гх</p>" data-abbr="костылики">костылики))

Есть наработки с генеративками или чем-то вроде 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

  • Ещё одна статья автора: https://habr.com/ru/articles/733958/

    • Моя первая статья на хабре про «анализатор ников» на FRED-T5

Автор: 3ndetz

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js