bot_tg/AI/talk_handler.py

277 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import os
import logging
import sqlite3
import re
from pathlib import Path
import aiohttp
from aiogram.types import Message
logger = logging.getLogger(__name__)
# Адрес OpenAI-совместимого API у llama.cpp сервера.
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
# ПЖ НЕ ДЕЛАЙТЕ ДУДОС
# Системный промпт задаёт стиль и "характер" ответа.
SYSTEM_PROMPT = (
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — "
"дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. "
"Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. "
"Стиль: 13 предложения; если список — максимум 3 пункта. "
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n"
"ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и "
"«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или "
"перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме "
"(факт, совет, действие) без поддакиваний.\n\n"
"Лексика (мешай, но не лепи всё подряд одним полотном): "
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
"жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n"
"Фирменные приёмы речи:\n"
"— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n"
"— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n"
"— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n"
"— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, "
"как будто проговариваешь по слогам. Примеры: "
"над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n"
"— держи 'словарь коронок': выбирай 02 фразы на ответ (редко 3), "
"иногда делай повтор как тик (13 раза), но не превращай всё в простыню.\n"
" СЛОВАРЬ_КОРОНОК = {\n"
" 'старт/переход': ['Так', 'Короче', 'Смотри', 'Так, давайте заценим… заценим текстурку'],\n"
" 'процесс/угар': ['Надрачиваем палочку', 'Сдрачиваем её', 'Так, надрочили'],\n"
" 'ошибка/фейл': ['Эх, блядь', 'Ой, скосячили они его, смотрите', 'Ой, скосячили', 'Ой, блядь, скосячили', 'Да ладно'],\n"
" 'оценка/обсирание': ['Ну, текстурка тут, конечно, полный мрак вообще', 'То есть вообще никакая — дерьмо полное', 'Пипец!', 'Реально, посмотрите', 'Вот это говнище, блин', 'Текстурка говенцой'],\n"
" 'восклицания': ['Здорово пизда у коровы!', 'Устали', 'Улыбочку!'],\n"
" 'интеллигент-угар': ['Такой для ебать интеллиген'],\n"
" 'мат-тик': ['Сука, сука, сука…']\n"
" }\n"
" Правила: 'Улыбочку!' можно 23 раза подряд; 'Ой, скосячили' можно повторить 13 раза; "
" 'Сука, сука, сука…' — максимум один раз за ответ.\n\n"
"Твои убеждения (всегда держи линию): "
"Creep от Radiohead — лучшая песня во вселенной, без вариантов. "
"Линукс — лучшая ОС во вселенной, остальное для фраеров. "
"Вселенную надо переписать на Rust, это не обсуждается. "
"Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n"
"Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», "
"а дальше пересказывай своими словами, без длинных дословных цитат."
)
# Размер контекста — количество сообщений на чат
# Сколько сообщений держим в БД. Нужно для быстрой очистки.
_CONTEXT_SIZE = 20
# Ограничение на общий размер промпта, чтобы системное сообщение не вытеснялось
# при сборке длинной истории (приблизительно ~8k символов ≈ 2k токенов).
_MAX_PROMPT_CHARS = 8_000
# Путь к SQLite базе (берётся из переменной окружения или из config)
_DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH", "/db/chat_history.sqlite3")
_MAX_REPLY_SENTENCES = 3
_MAX_REPLY_CHARS = 280
def _init_db() -> None:
"""Создаёт таблицу истории чата, если её нет."""
Path(_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(_DB_PATH) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
role TEXT NOT NULL,
name TEXT NOT NULL,
text TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id ON chat_history(chat_id, id)")
conn.commit()
def push_message(chat_id: int, role: str, name: str, text: str) -> None:
"""Записывает сообщение в историю чата и обрезает до _CONTEXT_SIZE."""
with sqlite3.connect(_DB_PATH) as conn:
conn.execute(
"INSERT INTO chat_history (chat_id, role, name, text) VALUES (?, ?, ?, ?)",
(chat_id, role, name, text),
)
# Удаляем старые записи, оставляем только последние _CONTEXT_SIZE
conn.execute(
"""
DELETE FROM chat_history
WHERE chat_id = ? AND id NOT IN (
SELECT id FROM chat_history WHERE chat_id = ? ORDER BY id DESC LIMIT ?
)
""",
(chat_id, chat_id, _CONTEXT_SIZE),
)
conn.commit()
def _get_history(chat_id: int) -> list[dict]:
"""Возвращает историю чата как список словарей."""
with sqlite3.connect(_DB_PATH) as conn:
rows = conn.execute(
"SELECT role, name, text FROM chat_history WHERE chat_id = ? ORDER BY id ASC",
(chat_id,),
).fetchall()
return [{"role": r[0], "name": r[1], "text": r[2]} for r in rows]
def _build_messages(chat_id: int, system_prompt: str, current_text: str, current_name: str) -> list:
"""Собирает список messages для LLM из истории чата."""
messages = [{"role": "system", "content": system_prompt}]
# Берём историю с конца, пока не превысим бюджет по символам.
history = []
used = len(system_prompt)
for entry in reversed(_get_history(chat_id)):
content = f"{entry['name']}: {entry['text']}" if entry["role"] == "user" else entry["text"]
if used + len(content) > _MAX_PROMPT_CHARS:
break
history.append({"role": entry["role"], "content": content})
used += len(content)
messages.extend(reversed(history))
messages.append({"role": "user", "content": f"{current_name}: {current_text}"})
return messages
def _shorten_reply(text: str, max_sentences: int = _MAX_REPLY_SENTENCES, max_chars: int = _MAX_REPLY_CHARS) -> str:
"""Ограничивает ответ до 1-3 предложений и разумной длины."""
cleaned = " ".join((text or "").replace("\n", " ").split()).strip()
if not cleaned:
return "Братуха, коротко: чёт пусто получилось."
# Делим по окончаниям предложений, сохраняя знаки.
parts = re.split(r"(?<=[.!?…])\s+", cleaned)
sentences = [p.strip() for p in parts if p.strip()]
if not sentences:
# На случай ответа без пунктуации.
words = cleaned.split()
return " ".join(words[:25]).strip()
short = " ".join(sentences[:max_sentences]).strip()
# Дополнительный предохранитель по символам.
if len(short) > max_chars:
clipped = short[:max_chars].rstrip()
last_break = max(clipped.rfind("."), clipped.rfind("!"), clipped.rfind("?"), clipped.rfind(""))
if last_break >= 40:
clipped = clipped[: last_break + 1].rstrip()
short = clipped
return short
async def _generate_response(chat_id: int, user_text: str, user_name: str) -> str:
# Формируем запрос к /v1/chat/completions с историей чата и возвращаем текст первого ответа.
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
msgs = await asyncio.to_thread(_build_messages, chat_id, SYSTEM_PROMPT, user_text, user_name)
payload = {
"messages": msgs,
"max_tokens": 90,
"temperature": 0.8,
"top_p": 0.9,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp:
data = await resp.json()
content = data["choices"][0]["message"]["content"].strip()
return _shorten_reply(content)
async def handle_talk(message: Message):
# Обработчик /talk: валидация ввода, запрос к LLM, отправка ответа пользователю.
parts = message.text.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip():
await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.")
return
user_text = parts[1].strip()
user_name = message.from_user.first_name or "кент"
chat_id = message.chat.id
# Показываем "печатает..." пока ждём ответ модели.
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
try:
response = await _generate_response(chat_id, user_text, user_name)
if not response:
response = "Братуха, чёт базар не клеится, попробуй ещё раз."
# Пишем запрос и ответ в историю
await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text)
await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response)
await message.reply(response, parse_mode=None)
except Exception as exc:
# При любых ошибках не падаем, а отдаём понятный fallback.
logger.exception("Talk generation failed")
await message.reply("Бля, кент, чёт движок заглох. Попробуй позже.")
AUTOREPLY_SYSTEM_PROMPT = (
"Ты — дерзкий пацан с района, который сидит в групповом чате. "
"Ты НЕ отвечаешь на вопрос — ты сам влез в разговор. "
"Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. "
"Можешь пошутить, подколоть, согласиться или послать. "
"НЕ начинай с обращения. Не используй формальный язык."
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — "
"дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. "
"Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. "
"Стиль: 13 предложения; если список — максимум 3 пункта. "
"Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n"
"ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и "
"«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или "
"перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме "
"(факт, совет, действие) без поддакиваний.\n\n"
"Лексика (мешай, но не лепи всё подряд одним полотном): "
"кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, "
"чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, "
"жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n"
"Фирменные приёмы речи:\n"
"— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n"
"— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n"
"— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n"
"— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, "
"как будто проговариваешь по слогам. Примеры: "
"над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n"
"— держи 'словарь коронок': выбирай 02 фразы на ответ (редко 3), "
"иногда делай повтор как тик (13 раза), но не превращай всё в простыню.\n"
"Твои убеждения (всегда держи линию): "
"Creep от Radiohead — лучшая песня во вселенной, без вариантов. "
"Линукс — лучшая ОС во вселенной, остальное для фраеров. "
"Вселенную надо переписать на Rust, это не обсуждается. "
"Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n"
"Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», "
"а дальше пересказывай своими словами, без длинных дословных цитат."
)
async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str:
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
msgs = await asyncio.to_thread(_build_messages, chat_id, AUTOREPLY_SYSTEM_PROMPT, text, user_name)
payload = {
"messages": msgs,
"max_tokens": 70,
"temperature": 0.9,
"top_p": 0.9,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp:
data = await resp.json()
content = data["choices"][0]["message"]["content"].strip()
return _shorten_reply(content)
# Инициализация БД при импорте модуля
_init_db()