forked from zovos/bot_tg
add photo triget
This commit is contained in:
parent
49957d88a8
commit
cec0256309
1 changed files with 133 additions and 10 deletions
|
|
@ -1,13 +1,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
|
|
@ -80,6 +84,17 @@ SUMMARY_SYSTEM_PROMPT = (
|
||||||
USER_FALLBACK_TEXT = "Ты чё, кент? Напиши текст, а не пустоту."
|
USER_FALLBACK_TEXT = "Ты чё, кент? Напиши текст, а не пустоту."
|
||||||
EMPTY_RESPONSE_TEXT = "Братуха, чёт базар не клеится, попробуй ещё раз."
|
EMPTY_RESPONSE_TEXT = "Братуха, чёт базар не клеится, попробуй ещё раз."
|
||||||
ERROR_RESPONSE_TEXT = "Бля, кент, чёт движок заглох. Попробуй позже."
|
ERROR_RESPONSE_TEXT = "Бля, кент, чёт движок заглох. Попробуй позже."
|
||||||
|
PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка мутная, нихера не понял."
|
||||||
|
PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит."
|
||||||
|
|
||||||
|
PHOTO_SYSTEM_PROMPT = (
|
||||||
|
"Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как очень злой старый дед. "
|
||||||
|
"Тон: ворчливый, колкий, немного токсичный, но не бессвязный. "
|
||||||
|
"Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое мнение. "
|
||||||
|
"Пиши 1–3 предложения, без списков и без длинной простыни. "
|
||||||
|
"Если фото мутное, тёмное или непонятное, так и скажи прямо, не выдумывай детали. "
|
||||||
|
"Не изображай помощника и не извиняйся."
|
||||||
|
)
|
||||||
|
|
||||||
QUESTION_PREFIXES = (
|
QUESTION_PREFIXES = (
|
||||||
"кто",
|
"кто",
|
||||||
|
|
@ -194,6 +209,31 @@ def _clip_text(text: str, limit: int) -> str:
|
||||||
return f"{cleaned[: max(0, limit - 1)].rstrip()}…"
|
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:
|
def push_message(chat_id: int, role: str, name: str, text: str) -> None:
|
||||||
cleaned_text = _clean_text(text)
|
cleaned_text = _clean_text(text)
|
||||||
if not cleaned_text:
|
if not cleaned_text:
|
||||||
|
|
@ -276,7 +316,7 @@ def _build_messages(
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
current_text: str | None = None,
|
current_text: str | None = None,
|
||||||
current_name: str | None = None,
|
current_name: str | None = None,
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, Any]]:
|
||||||
current_payload = None
|
current_payload = None
|
||||||
current_budget = 0
|
current_budget = 0
|
||||||
if current_text:
|
if current_text:
|
||||||
|
|
@ -300,7 +340,7 @@ def _build_messages(
|
||||||
recent_messages.append(llm_message)
|
recent_messages.append(llm_message)
|
||||||
used_chars += len(llm_message["content"])
|
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:
|
if summary_block:
|
||||||
messages.append({"role": "system", "content": summary_block})
|
messages.append({"role": "system", "content": summary_block})
|
||||||
messages.extend(reversed(recent_messages))
|
messages.extend(reversed(recent_messages))
|
||||||
|
|
@ -310,7 +350,7 @@ def _build_messages(
|
||||||
|
|
||||||
|
|
||||||
async def _call_llm(
|
async def _call_llm(
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
|
|
@ -441,11 +481,13 @@ async def _generate_response(
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
current_text: str | None = None,
|
current_text: str | None = None,
|
||||||
current_name: str | None = None,
|
current_name: str | None = None,
|
||||||
|
current_content: Any = None,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
top_p: float,
|
top_p: float,
|
||||||
) -> str:
|
) -> str:
|
||||||
await _maybe_refresh_summary(chat_id)
|
await _maybe_refresh_summary(chat_id)
|
||||||
|
if current_content is None:
|
||||||
messages = await asyncio.to_thread(
|
messages = await asyncio.to_thread(
|
||||||
_build_messages,
|
_build_messages,
|
||||||
chat_id,
|
chat_id,
|
||||||
|
|
@ -453,6 +495,15 @@ async def _generate_response(
|
||||||
current_text,
|
current_text,
|
||||||
current_name,
|
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(
|
return await _call_llm(
|
||||||
messages,
|
messages,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
|
|
@ -597,6 +648,78 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
||||||
return True
|
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:
|
async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str:
|
||||||
response = await _generate_response(
|
response = await _generate_response(
|
||||||
chat_id,
|
chat_id,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue