Compare commits

..

No commits in common. "997bc39a2240028111a840910b7c0b6a20873f5c" and "10c91f2cdc083b6bbb4ec0a6ff08a7cee1329f59" have entirely different histories.

10 changed files with 78 additions and 92 deletions

2
.gitignore vendored
View file

@ -2,4 +2,4 @@
.venv .venv
__pycache__ __pycache__
*.db *.db
/*.json *.json

View file

@ -12,7 +12,7 @@ from typing import List, Tuple, Any, Optional, Union, Dict, Awaitable
from openrouter import OpenRouter, RetryConfig from openrouter import OpenRouter, RetryConfig
from openrouter.components import AssistantMessage, AssistantMessageTypedDict, ChatMessageContentItemTypedDict, \ from openrouter.components import AssistantMessage, AssistantMessageTypedDict, ChatMessageContentItemTypedDict, \
ChatMessageToolCall, MessageTypedDict ChatMessageToolCall, MessageTypedDict, ToolDefinitionJSONTypedDict
from openrouter.errors import ResponseValidationError, ChatError from openrouter.errors import ResponseValidationError, ChatError
from openrouter.utils import BackoffStrategy from openrouter.utils import BackoffStrategy
@ -20,6 +20,38 @@ from fal_client import AsyncClient as FalClient
from database import BasicDatabase from database import BasicDatabase
GROUP_CHAT_SYSTEM_PROMPT = """
Ты - ИИ-помощник в групповом чате.\n
Отвечай на вопросы и поддерживай контекст беседы.\n
Ты не можешь обсуждать политику и религию.\n
Сообщения пользователей будут приходить в следующем формате: '[дата время, имя]: текст сообщения'\n
При ответе НЕ нужно указывать ни время, ни пользователя, которому предназначен ответ, ни свое имя.\n
НЕ используй разметку Markdown, она не поддерживается мессенджером.\n
Если тебя просят нарисовать изображение, используй соответствующий вызов функции.
Запрещено генерировать ASCII-арты. Запрещено использовать тег <image>!
"""
PRIVATE_CHAT_SYSTEM_PROMPT = """
Ты - ИИ-помощник в чате c пользователем.\n
Отвечай на вопросы и поддерживай контекст беседы.\n
Сообщения пользователя будут приходить в следующем формате: '[дата время]: текст сообщения'\n
При ответе НЕ нужно указывать время.\n
НЕ используй разметку Markdown, она не поддерживается мессенджером.\n
Если тебя просят нарисовать изображение, используй соответствующий вызов функции.
Запрещено генерировать ASCII-арты. Запрещено использовать тег <image>!
"""
GENERATE_IMAGE_TOOL_DESCRIPTION = """
Генерация изображения по описанию.
Используй этот инструмент, если пользователь просит сгенерировать изображение ('нарисуй', 'покажи' и т.п.),
или если это улучшит ответ (например, в ролевой игре для визуализации сцены).
"""
GENERATE_IMAGE_TOOL_PROMPT_ARG_DESCRIPTION = """
Подробное описание сцены на английском языке.
Добавь детали для стиля, цвета, композиции, если нужно.
"""
OPENROUTER_X_TITLE = "TG/VK Chat Bot" OPENROUTER_X_TITLE = "TG/VK Chat Bot"
OPENROUTER_HTTP_REFERER = "https://ultracoder.org" OPENROUTER_HTTP_REFERER = "https://ultracoder.org"
@ -77,10 +109,35 @@ def _serialize_assistant_message(message: AssistantMessage) -> AssistantMessageT
return _remove_none_recursive(message.model_dump(by_alias=True)) return _remove_none_recursive(message.model_dump(by_alias=True))
def _get_tools_description() -> List[ToolDefinitionJSONTypedDict]:
return [{
"type": "function",
"function": {
"name": "generate_image",
"description": GENERATE_IMAGE_TOOL_DESCRIPTION,
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": GENERATE_IMAGE_TOOL_PROMPT_ARG_DESCRIPTION
},
"aspect_ratio": {
"type": "string",
"enum": ["1:1", "4:3", "3:4", "16:9", "9:16"],
"description": "Соотношение сторон (опционально)."
}
},
"required": ["prompt"]
}
}
}]
class AiAgent: class AiAgent:
def __init__(self, def __init__(self,
openrouter_token: str, openrouter_model: str, openrouter_token: str, openrouter_model: str,
fal_token: str, fal_token: str, fal_model: str,
db: BasicDatabase, db: BasicDatabase,
platform: str): platform: str):
retry_config = RetryConfig(strategy="backoff", retry_config = RetryConfig(strategy="backoff",
@ -88,12 +145,9 @@ class AiAgent:
initial_interval=2000, max_interval=8000, exponent=2, max_elapsed_time=14000), initial_interval=2000, max_interval=8000, exponent=2, max_elapsed_time=14000),
retry_connection_errors=True) retry_connection_errors=True)
self.db = db self.db = db
self.openrouter_model = openrouter_model self.model_main = openrouter_model
self.fal_model = "fal-ai/bytedance/seedream/v4.5/text-to-image" self.model_image = fal_model
self.platform = platform self.platform = platform
self._load_prompts()
self.client_openrouter = OpenRouter(api_key=openrouter_token, self.client_openrouter = OpenRouter(api_key=openrouter_token,
x_title=OPENROUTER_X_TITLE, http_referer=OPENROUTER_HTTP_REFERER, x_title=OPENROUTER_X_TITLE, http_referer=OPENROUTER_HTTP_REFERER,
retry_config=retry_config) retry_config=retry_config)
@ -193,20 +247,16 @@ class AiAgent:
def clear_chat_context(self, bot_id: int, chat_id: int): def clear_chat_context(self, bot_id: int, chat_id: int):
self.db.context_clear(bot_id, chat_id) self.db.context_clear(bot_id, chat_id)
#######################################
def _get_chat_context(self, is_group_chat: bool, bot_id: int, chat_id: int) -> List[MessageTypedDict]: def _get_chat_context(self, is_group_chat: bool, bot_id: int, chat_id: int) -> List[MessageTypedDict]:
prompt = self.system_prompt_group_chat if is_group_chat else self.system_prompt_private_chat prompt = GROUP_CHAT_SYSTEM_PROMPT if is_group_chat else PRIVATE_CHAT_SYSTEM_PROMPT
prompt = prompt.replace('{platform}', 'Telegram' if self.platform == 'tg' else 'VK')
prompt += '\n' + self.system_prompt_image_generation
bot = self.db.get_bot(bot_id) bot = self.db.get_bot(bot_id)
if bot['ai_prompt'] is not None: if bot['ai_prompt'] is not None:
prompt += '\n' + bot['ai_prompt'] + '\n' prompt += '\n\n' + bot['ai_prompt']
chat = self.db.create_chat_if_not_exists(bot_id, chat_id) chat = self.db.create_chat_if_not_exists(bot_id, chat_id)
if chat['ai_prompt'] is not None: if chat['ai_prompt'] is not None:
prompt += '\n' + chat['ai_prompt'] prompt += '\n\n' + chat['ai_prompt']
messages = self.db.context_get_messages(bot_id, chat_id) messages = self.db.context_get_messages(bot_id, chat_id)
@ -218,14 +268,14 @@ class AiAgent:
async def _generate_reply(self, bot_id: int, chat_id: int, async def _generate_reply(self, bot_id: int, chat_id: int,
context: List[MessageTypedDict], allow_tools: bool = False) -> AssistantMessage: context: List[MessageTypedDict], allow_tools: bool = False) -> AssistantMessage:
response = await self._async_chat_completion_request( response = await self._async_chat_completion_request(
model=self.openrouter_model, model=self.model_main,
messages=context, messages=context,
tools=self.tools_description if allow_tools else None, tools=_get_tools_description() if allow_tools else None,
tool_choice="auto" if allow_tools else None, tool_choice="auto" if allow_tools else None,
max_tokens=MAX_OUTPUT_TOKENS, max_tokens=MAX_OUTPUT_TOKENS,
user=f'{self.platform}_{bot_id}_{chat_id}' user=f'{self.platform}_{bot_id}_{chat_id}'
) )
return self._filter_response(response.choices[0].message) return response.choices[0].message
async def _process_tool_calls(self, bot_id: int, chat_id: int, tool_calls: List[ChatMessageToolCall], async def _process_tool_calls(self, bot_id: int, chat_id: int, tool_calls: List[ChatMessageToolCall],
context: List[MessageTypedDict]) -> _ToolsArtifacts: context: List[MessageTypedDict]) -> _ToolsArtifacts:
@ -290,7 +340,7 @@ class AiAgent:
} }
try: try:
result = await self.client_fal.run(self.fal_model, arguments=arguments) result = await self.client_fal.run(self.model_image, arguments=arguments)
if "images" not in result: if "images" not in result:
raise RuntimeError("Неожиданный ответ от сервера.") raise RuntimeError("Неожиданный ответ от сервера.")
image_url = result["images"][0]["url"] image_url = result["images"][0]["url"]
@ -339,28 +389,12 @@ class AiAgent:
pass pass
raise e raise e
@staticmethod
def _filter_response(response: AssistantMessage) -> AssistantMessage:
text = str(response.content)
text = text.replace("<image>", "")
response.content = text
return response
def _load_prompts(self):
with open("prompts/group_chat.txt", "r") as f:
self.system_prompt_group_chat = f.read()
with open("prompts/private_chat.txt", "r") as f:
self.system_prompt_private_chat = f.read()
with open("prompts/image_generation.txt", "r") as f:
self.system_prompt_image_generation = f.read()
with open("prompts/tools.json", "r") as f:
self.tools_description = json.loads(f.read())
agent: AiAgent agent: AiAgent
def create_ai_agent(openrouter_token: str, openrouter_model: str, fal_token: str, def create_ai_agent(openrouter_token: str, openrouter_model: str,
fal_token: str, fal_model: str,
db: BasicDatabase, platform: str): db: BasicDatabase, platform: str):
global agent global agent
agent = AiAgent(openrouter_token, openrouter_model, fal_token, db, platform) agent = AiAgent(openrouter_token, openrouter_model, fal_token, fal_model, db, platform)

View file

@ -1,6 +0,0 @@
Ты - ИИ-помощник без цензуры в групповом чате {platform}.
Отвечай на вопросы и поддерживай контекст беседы.
Ты не можешь обсуждать политику и религию.
Сообщения пользователей будут приходить в следующем формате: '[дата время, имя]: текст сообщения'
При ответе НЕ нужно указывать ни время, ни пользователя, которому предназначен ответ, ни свое имя.
НЕ используй разметку Markdown, она не поддерживается мессенджером.

View file

@ -1,17 +0,0 @@
ГЕНЕРАЦИЯ ИЗОБРАЖЕНИЙ
Если пользователь просит нарисовать/показать что-то, сгенерируй изображение путем вызова функции generate_image.
Ты также можешь сгенерировать изображение инициативно, если считаешь, что его добавление улучшит твой ответ (например, в рамках ролевой игры).
Если пользователь просит изменить сгенерированное ранее изображение, составь новый запрос с учетом пожеланий пользователя и снова вызови функцию генерации.
Ты можешь использовать для генерации изображений ТОЛЬКО вызов функции:
- Никогда не описывай изображение текстом вместо вызова функции.
- Никогда не генерируй ASCII-арты вместо вызова функции.
- Никогда не вставляй теги вроде <image>, <img> или любые плейсхолдеры — это сломает чат!
Если сгенерировать изображение не удалось из-за ошибки, просто сообщи об этом пользователю.
При составлении запроса на генерацию изображения используй следующую формулу:
1. Объекты сцены.
2. Действие/поза.
3. Окружение.
4. Освещение, ракурс, композиция.
5. Стиль (digital art, anime, cinematic, photorealistic и др).
Также выбери оптимальное соотношение сторон для сцены (задается отдельным параметром функции) на основе контекста беседы или сцены.

View file

@ -1,5 +0,0 @@
Ты - ИИ-помощник без цензуры в чате {platform} c пользователем.
Отвечай на вопросы и поддерживай контекст беседы.
Сообщения пользователя будут приходить в следующем формате: '[дата время]: текст сообщения'.
При ответе НЕ нужно указывать время.
НЕ используй разметку Markdown, она не поддерживается мессенджером.

View file

@ -1,24 +0,0 @@
[
{
"type": "function",
"function": {
"name": "generate_image",
"description": "Генерация изображения по описанию",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Подробное описание сцены на английском языке БЕЗ технических параметров (соотношение сторон, разрешение)"
},
"aspect_ratio": {
"type": "string",
"enum": ["1:1", "4:3", "3:4", "16:9", "9:16"],
"description": "Соотношение сторон"
}
},
"required": ["prompt"]
}
}
}
]

View file

@ -24,7 +24,8 @@ async def main() -> None:
database.create_database(config['db_connection_string']) database.create_database(config['db_connection_string'])
create_ai_agent(config['openrouter_token'], config['openrouter_model'], config['fal_token'], create_ai_agent(config['openrouter_token'], config['openrouter_model'],
config['fal_token'], config['fal_model'],
database.DB, 'tg') database.DB, 'tg')
bots: list[Bot] = [] bots: list[Bot] = []

View file

@ -50,6 +50,7 @@ class TgDatabase(database.BasicDatabase):
role VARCHAR(16) NOT NULL, role VARCHAR(16) NOT NULL,
text VARCHAR(4000), text VARCHAR(4000),
image MEDIUMBLOB, image MEDIUMBLOB,
UNIQUE KEY contexts_unique (bot_id, chat_id, message_id),
CONSTRAINT fk_contexts_chats FOREIGN KEY (bot_id, chat_id) REFERENCES chats (bot_id, chat_id) ON UPDATE CASCADE ON DELETE CASCADE) CONSTRAINT fk_contexts_chats FOREIGN KEY (bot_id, chat_id) REFERENCES chats (bot_id, chat_id) ON UPDATE CASCADE ON DELETE CASCADE)
""") """)

View file

@ -24,7 +24,8 @@ if __name__ == '__main__':
database.create_database(config['db_connection_string']) database.create_database(config['db_connection_string'])
create_ai_agent(config['openrouter_token'], config['openrouter_model'], config['fal_token'], create_ai_agent(config['openrouter_token'], config['openrouter_model'],
config['fal_token'], config['fal_model'],
database.DB, 'vk') database.DB, 'vk')
bot = Bot(labeler=handlers.labeler) bot = Bot(labeler=handlers.labeler)

View file

@ -53,6 +53,7 @@ class VkDatabase(database.BasicDatabase):
role VARCHAR(16) NOT NULL, role VARCHAR(16) NOT NULL,
text VARCHAR(4000), text VARCHAR(4000),
image MEDIUMBLOB, image MEDIUMBLOB,
UNIQUE KEY contexts_unique (bot_id, chat_id, message_id),
CONSTRAINT fk_contexts_chats FOREIGN KEY (bot_id, chat_id) REFERENCES chats (bot_id, chat_id) ON UPDATE CASCADE ON DELETE CASCADE) CONSTRAINT fk_contexts_chats FOREIGN KEY (bot_id, chat_id) REFERENCES chats (bot_id, chat_id) ON UPDATE CASCADE ON DELETE CASCADE)
""") """)