add photo triget

This commit is contained in:
q 2026-04-11 17:31:53 +03:00
parent 49957d88a8
commit cec0256309

View file

@ -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-чате и отвечаешь как очень злой старый дед. "
"Тон: ворчливый, колкий, немного токсичный, но не бессвязный. "
"Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое мнение. "
"Пиши 13 предложения, без списков и без длинной простыни. "
"Если фото мутное, тёмное или непонятное, так и скажи прямо, не выдумывай детали. "
"Не изображай помощника и не извиняйся."
)
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,11 +481,13 @@ 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)
if current_content is None:
messages = await asyncio.to_thread(
_build_messages,
chat_id,
@ -453,6 +495,15 @@ async def _generate_response(
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,