From cec02563098a82b3b11c4f1151bad570f3a9ad39 Mon Sep 17 00:00:00 2001 From: q Date: Sat, 11 Apr 2026 17:31:53 +0300 Subject: [PATCH] add photo triget --- AI/talk_handler.py | 143 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 10 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 658de7c..216a2f2 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -1,13 +1,17 @@ from __future__ import annotations import asyncio +import base64 import logging +import mimetypes import os import random import re import sqlite3 import time +from io import BytesIO from pathlib import Path +from typing import Any import aiohttp from aiogram.types import Message @@ -80,6 +84,17 @@ SUMMARY_SYSTEM_PROMPT = ( USER_FALLBACK_TEXT = "Ты чё, кент? Напиши текст, а не пустоту." EMPTY_RESPONSE_TEXT = "Братуха, чёт базар не клеится, попробуй ещё раз." ERROR_RESPONSE_TEXT = "Бля, кент, чёт движок заглох. Попробуй позже." +PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка мутная, нихера не понял." +PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит." + +PHOTO_SYSTEM_PROMPT = ( + "Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как очень злой старый дед. " + "Тон: ворчливый, колкий, немного токсичный, но не бессвязный. " + "Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое мнение. " + "Пиши 1–3 предложения, без списков и без длинной простыни. " + "Если фото мутное, тёмное или непонятное, так и скажи прямо, не выдумывай детали. " + "Не изображай помощника и не извиняйся." +) QUESTION_PREFIXES = ( "кто", @@ -194,6 +209,31 @@ def _clip_text(text: str, limit: int) -> str: return f"{cleaned[: max(0, limit - 1)].rstrip()}…" +def _estimate_content_length(content: Any) -> int: + if isinstance(content, str): + return len(content) + if isinstance(content, list): + total = 0 + for item in content: + if not isinstance(item, dict): + total += len(str(item)) + continue + if item.get("type") == "text": + total += len(str(item.get("text", ""))) + elif item.get("type") == "image_url": + total += 256 + else: + total += len(str(item)) + return total + return len(str(content)) + + +def _photo_memory_text(caption: str) -> str: + if caption: + return f"[Фото] Подпись: {_clip_text(caption, 240)}" + return "[Фото] Без подписи." + + def push_message(chat_id: int, role: str, name: str, text: str) -> None: cleaned_text = _clean_text(text) if not cleaned_text: @@ -276,7 +316,7 @@ def _build_messages( system_prompt: str, current_text: str | None = None, current_name: str | None = None, -) -> list[dict[str, str]]: +) -> list[dict[str, Any]]: current_payload = None current_budget = 0 if current_text: @@ -300,7 +340,7 @@ def _build_messages( recent_messages.append(llm_message) used_chars += len(llm_message["content"]) - messages = [{"role": "system", "content": system_prompt}] + messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] if summary_block: messages.append({"role": "system", "content": summary_block}) messages.extend(reversed(recent_messages)) @@ -310,7 +350,7 @@ def _build_messages( async def _call_llm( - messages: list[dict[str, str]], + messages: list[dict[str, Any]], *, max_tokens: int, temperature: float, @@ -441,18 +481,29 @@ async def _generate_response( system_prompt: str, current_text: str | None = None, current_name: str | None = None, + current_content: Any = None, max_tokens: int, temperature: float, top_p: float, ) -> str: await _maybe_refresh_summary(chat_id) - messages = await asyncio.to_thread( - _build_messages, - chat_id, - system_prompt, - current_text, - current_name, - ) + if current_content is None: + messages = await asyncio.to_thread( + _build_messages, + chat_id, + system_prompt, + current_text, + current_name, + ) + else: + messages = await asyncio.to_thread(_build_messages, chat_id, system_prompt, None, None) + current_payload = {"role": "user", "content": current_content} + current_budget = _estimate_content_length(current_content) + used_chars = sum(_estimate_content_length(message.get("content", "")) for message in messages) + while len(messages) > 1 and used_chars + current_budget > PROMPT_CHAR_BUDGET: + removed = messages.pop(1) + used_chars -= _estimate_content_length(removed.get("content", "")) + messages.append(current_payload) return await _call_llm( messages, max_tokens=max_tokens, @@ -597,6 +648,78 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a return True +async def _download_photo_data_url(message: Message) -> str: + if not message.photo: + raise ValueError("Message has no photo") + telegram_file = await message.bot.get_file(message.photo[-1].file_id) + buffer = BytesIO() + await message.bot.download_file(telegram_file.file_path, destination=buffer) + image_bytes = buffer.getvalue() + if not image_bytes: + raise RuntimeError("Downloaded photo is empty") + mime_type = mimetypes.guess_type(telegram_file.file_path or "")[0] or "image/jpeg" + if not mime_type.startswith("image/"): + mime_type = "image/jpeg" + encoded = base64.b64encode(image_bytes).decode("ascii") + return f"data:{mime_type};base64,{encoded}" + + +async def handle_photo_message(message: Message) -> bool: + if not message.photo or not message.from_user or message.from_user.is_bot: + return False + if message.caption and message.caption.startswith("/"): + return False + + chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + caption = _clean_text(message.caption or "") + + await asyncio.to_thread(push_message, chat_id, "user", user_name, _photo_memory_text(caption)) + + async with _reply_lock: + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + image_data_url = await _download_photo_data_url(message) + prompt_text = ( + f"{user_name} скинул фото в чат. " + "Опиши, что на нём происходит, и вкинь своё злое дедовское мнение." + ) + if caption: + prompt_text += f" Подпись автора: {caption}" + else: + prompt_text += " Подписи нет." + + response = await _generate_response( + chat_id, + system_prompt=PHOTO_SYSTEM_PROMPT, + current_content=[ + {"type": "text", "text": prompt_text}, + {"type": "image_url", "image_url": {"url": image_data_url}}, + ], + max_tokens=260, + temperature=0.8, + top_p=0.9, + ) + except Exception: + logger.exception("Photo analysis failed") + await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None) + return True + + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Photo analysis produced empty/skip response. chat_id=%s user=%s raw=%s", + chat_id, + user_name, + _clip_text(response, 200), + ) + normalized_response = PHOTO_EMPTY_RESPONSE_TEXT + + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) + return True + + async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str: response = await _generate_response( chat_id,