no more forbidden things
This commit is contained in:
parent
b6274f7106
commit
d037834938
5 changed files with 195 additions and 210 deletions
|
|
@ -1,9 +1,11 @@
|
|||
"""
|
||||
Telegram-бот с четырьмя функциями:
|
||||
1) получает изображение → возвращает его описание (vision-модель Qwen);
|
||||
2) /plot <описание> → matplotlib-график;
|
||||
3) /arch <описание системы> → архитектурная схема через Graphviz;
|
||||
4) /repro (в подписи или ответе к фото) → воспроизводит график/схему с картинки.
|
||||
Консольное приложение с четырьмя функциями:
|
||||
1) describe <путь_к_картинке> — описание изображения (vision-модель Qwen);
|
||||
2) plot <описание> — matplotlib-график (сохраняется в plot.png);
|
||||
3) arch <описание системы> — архитектурная схема через Graphviz (arch.png);
|
||||
4) repro <путь_к_картинке> — воспроизводит график/схему с картинки (repro.png).
|
||||
|
||||
Команды вводятся в интерактивном режиме. Для выхода: exit / quit / Ctrl+C.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -15,42 +17,31 @@ import logging
|
|||
import asyncio
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # без GUI-бэкенда, мы только сохраняем в буфер
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import graphviz
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from openai import AsyncOpenAI
|
||||
from telegram import Update
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
MessageHandler,
|
||||
ContextTypes,
|
||||
filters,
|
||||
)
|
||||
|
||||
# ---------- конфиг ----------
|
||||
|
||||
load_dotenv()
|
||||
|
||||
TELEGRAM_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
LLM_API_KEY = os.getenv("LLM_API_KEY")
|
||||
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://llm.lambda.coredump.ru/v1")
|
||||
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3.5-122b")
|
||||
# Отключить thinking у reasoning-моделей (актуально для Qwen3/DeepSeek-R1 и т.п.),
|
||||
# чтобы все токены шли в финальный ответ, а не в <think>-блок.
|
||||
LLM_API_KEY = os.getenv("LLM_API_KEY")
|
||||
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://llm.lambda.coredump.ru/v1")
|
||||
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3.5-122b")
|
||||
DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
if not TELEGRAM_TOKEN or not LLM_API_KEY:
|
||||
raise RuntimeError("В .env должны быть заданы TELEGRAM_BOT_TOKEN и LLM_API_KEY")
|
||||
if not LLM_API_KEY:
|
||||
raise RuntimeError("В .env должна быть задана переменная LLM_API_KEY")
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(levelname)s | %(message)s",
|
||||
level=logging.WARNING, # в консоли не засоряем вывод INFO-логами
|
||||
)
|
||||
logger = logging.getLogger("qwen-bot")
|
||||
logger = logging.getLogger("qwen-app")
|
||||
|
||||
client = AsyncOpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
|
||||
|
||||
|
|
@ -95,7 +86,7 @@ async def describe_image(image_bytes: bytes) -> str:
|
|||
],
|
||||
}
|
||||
],
|
||||
**_chat_kwargs(max_tokens=1024),
|
||||
**_chat_kwargs(max_tokens=8192),
|
||||
)
|
||||
return _extract_content(response).strip()
|
||||
|
||||
|
|
@ -149,7 +140,7 @@ def render_plot(code: str) -> bytes:
|
|||
Выполняет сгенерированный код и возвращает PNG-байты.
|
||||
|
||||
ВНИМАНИЕ: exec используется в ограниченном неймспейсе, но это не настоящая
|
||||
песочница. Для публичного бота лучше изолировать исполнение (отдельный
|
||||
песочница. Для публичного приложения лучше изолировать исполнение (отдельный
|
||||
процесс с resource-лимитами, Docker, nsjail и т. п.).
|
||||
"""
|
||||
plt.close("all")
|
||||
|
|
@ -200,7 +191,7 @@ async def generate_arch_dot(description: str) -> str:
|
|||
{"role": "system", "content": ARCH_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": description},
|
||||
],
|
||||
**_chat_kwargs(max_tokens=8192), # DOT + возможный reasoning могут съесть много
|
||||
**_chat_kwargs(max_tokens=8192),
|
||||
)
|
||||
return _strip_code_fences(_extract_content(response))
|
||||
|
||||
|
|
@ -275,145 +266,144 @@ async def analyze_and_reproduce(image_bytes: bytes) -> tuple[str, str, str]:
|
|||
)
|
||||
|
||||
|
||||
# ---------- Telegram-хэндлеры ----------
|
||||
# ---------- вспомогательные утилиты ----------
|
||||
|
||||
async def cmd_start(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await update.message.reply_text(
|
||||
"Привет! Я умею:\n"
|
||||
"📷 описывать фото — просто пришли картинку\n"
|
||||
"📊 строить графики — команда /plot <описание>\n"
|
||||
"🏛 рисовать архитектурные схемы — команда /arch <описание системы>\n"
|
||||
"♻️ воспроизводить график/схему с фото — пришли фото с подписью /repro "
|
||||
"или ответь /repro на сообщение с фото\n\n"
|
||||
"Примеры:\n"
|
||||
" /plot sin(x) и cos(x) на отрезке [0, 2π]\n"
|
||||
" /arch React-фронт, FastAPI-бэкенд, Postgres и Redis за nginx"
|
||||
def read_image(path: str) -> bytes:
|
||||
"""Читает файл изображения с диска."""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Файл не найден: {path}")
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def save_png(png: bytes, filename: str) -> str:
|
||||
"""Сохраняет PNG-байты в файл рядом со скриптом и возвращает путь."""
|
||||
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(png)
|
||||
return out_path
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
print(
|
||||
"\nДоступные команды:\n"
|
||||
" describe <путь> — описать изображение\n"
|
||||
" plot <описание> — построить график (сохраняется в plot.png)\n"
|
||||
" arch <описание> — нарисовать архитектурную схему (arch.png)\n"
|
||||
" repro <путь> — воспроизвести график/схему с картинки (repro.png)\n"
|
||||
" help — эта справка\n"
|
||||
" exit / quit — выход\n"
|
||||
)
|
||||
|
||||
|
||||
async def on_photo(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await update.message.chat.send_action("typing")
|
||||
photo = update.message.photo[-1] # берём самый большой размер
|
||||
tg_file = await photo.get_file()
|
||||
image_bytes = bytes(await tg_file.download_as_bytearray())
|
||||
try:
|
||||
text = await describe_image(image_bytes)
|
||||
await update.message.reply_text(text)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка при описании изображения")
|
||||
await update.message.reply_text(f"Не смог разобрать изображение: {e}")
|
||||
# ---------- обработчики команд ----------
|
||||
|
||||
|
||||
async def cmd_plot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
request = " ".join(context.args).strip()
|
||||
if not request:
|
||||
await update.message.reply_text(
|
||||
"Опиши, какой график построить. Пример:\n/plot затухающая синусоида на [0, 10]"
|
||||
)
|
||||
async def handle_describe(arg: str) -> None:
|
||||
if not arg:
|
||||
print("Укажи путь к картинке. Пример: describe photo.jpg")
|
||||
return
|
||||
|
||||
await update.message.chat.send_action("upload_photo")
|
||||
try:
|
||||
code = await generate_plot_code(request)
|
||||
logger.info("Сгенерирован код:\n%s", code)
|
||||
png = await asyncio.to_thread(render_plot, code)
|
||||
await update.message.reply_photo(photo=png, caption=request[:1024])
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка при построении графика")
|
||||
await update.message.reply_text(f"Не получилось построить график: {e}")
|
||||
print("⏳ Анализирую изображение...")
|
||||
image_bytes = read_image(arg)
|
||||
text = await describe_image(image_bytes)
|
||||
print(f"\n{text}\n")
|
||||
|
||||
|
||||
async def cmd_arch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
description = " ".join(context.args).strip()
|
||||
if not description:
|
||||
await update.message.reply_text(
|
||||
async def handle_plot(arg: str) -> None:
|
||||
if not arg:
|
||||
print("Опиши, какой график построить. Пример: plot sin(x) на [0, 2π]")
|
||||
return
|
||||
print("⏳ Генерирую код и строю график...")
|
||||
code = await generate_plot_code(arg)
|
||||
logger.debug("Сгенерирован код:\n%s", code)
|
||||
png = await asyncio.to_thread(render_plot, code)
|
||||
path = save_png(png, "plot.png")
|
||||
print(f"✅ График сохранён: {path}\n")
|
||||
|
||||
|
||||
async def handle_arch(arg: str) -> None:
|
||||
if not arg:
|
||||
print(
|
||||
"Опиши архитектуру системы. Пример:\n"
|
||||
"/arch React-фронт, FastAPI-бэкенд с воркерами Celery, Postgres, Redis и S3"
|
||||
" arch React-фронт, FastAPI-бэкенд, Postgres и Redis за nginx"
|
||||
)
|
||||
return
|
||||
|
||||
await update.message.chat.send_action("upload_photo")
|
||||
try:
|
||||
dot = await generate_arch_dot(description)
|
||||
logger.info("Сгенерирован DOT:\n%s", dot)
|
||||
png = await asyncio.to_thread(render_architecture, dot)
|
||||
await update.message.reply_photo(photo=png, caption=description[:1024])
|
||||
except graphviz.ExecutableNotFound:
|
||||
logger.exception("Нет бинаря graphviz")
|
||||
await update.message.reply_text(
|
||||
"На сервере не установлен Graphviz. Поставь его: `apt install graphviz` или `brew install graphviz`."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка при построении архитектуры")
|
||||
await update.message.reply_text(f"Не получилось построить схему: {e}")
|
||||
print("⏳ Генерирую архитектурную схему...")
|
||||
dot = await generate_arch_dot(arg)
|
||||
logger.debug("Сгенерирован DOT:\n%s", dot)
|
||||
png = await asyncio.to_thread(render_architecture, dot)
|
||||
path = save_png(png, "arch.png")
|
||||
print(f"✅ Схема сохранена: {path}\n")
|
||||
|
||||
|
||||
async def cmd_repro(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""
|
||||
Воспроизводит график/схему с приложенного фото.
|
||||
Работает двумя способами:
|
||||
1) фото с подписью "/repro"
|
||||
2) команда "/repro" в ответ (reply) на сообщение с фото
|
||||
"""
|
||||
msg = update.message
|
||||
if msg.photo:
|
||||
source_msg = msg
|
||||
elif msg.reply_to_message and msg.reply_to_message.photo:
|
||||
source_msg = msg.reply_to_message
|
||||
async def handle_repro(arg: str) -> None:
|
||||
if not arg:
|
||||
print("Укажи путь к картинке. Пример: repro chart.png")
|
||||
return
|
||||
print("⏳ Анализирую и воспроизвожу...")
|
||||
image_bytes = read_image(arg)
|
||||
kind, code, notes = await analyze_and_reproduce(image_bytes)
|
||||
logger.debug("repro type=%s notes=%s\ncode:\n%s", kind, notes, code)
|
||||
|
||||
if kind == "plot":
|
||||
png = await asyncio.to_thread(render_plot, code)
|
||||
path = save_png(png, "repro.png")
|
||||
label = f"📊 {notes}" if notes else "📊 Воспроизведённый график"
|
||||
print(f"✅ {label}\n Сохранён: {path}\n")
|
||||
elif kind == "architecture":
|
||||
png = await asyncio.to_thread(render_architecture, code)
|
||||
path = save_png(png, "repro.png")
|
||||
label = f"🏛 {notes}" if notes else "🏛 Воспроизведённая схема"
|
||||
print(f"✅ {label}\n Сохранена: {path}\n")
|
||||
else:
|
||||
await msg.reply_text(
|
||||
"Пришли фото с подписью /repro или ответь /repro на сообщение с фото."
|
||||
)
|
||||
return
|
||||
|
||||
await msg.chat.send_action("upload_photo")
|
||||
tg_file = await source_msg.photo[-1].get_file()
|
||||
image_bytes = bytes(await tg_file.download_as_bytearray())
|
||||
|
||||
try:
|
||||
kind, code, notes = await analyze_and_reproduce(image_bytes)
|
||||
logger.info("repro type=%s notes=%s\ncode:\n%s", kind, notes, code)
|
||||
|
||||
if kind == "plot":
|
||||
png = await asyncio.to_thread(render_plot, code)
|
||||
caption = f"📊 {notes}" if notes else "📊 Воспроизведённый график"
|
||||
elif kind == "architecture":
|
||||
png = await asyncio.to_thread(render_architecture, code)
|
||||
caption = f"🏛 {notes}" if notes else "🏛 Воспроизведённая схема"
|
||||
else:
|
||||
await msg.reply_text(
|
||||
"Это не похоже ни на график, ни на архитектурную схему."
|
||||
+ (f"\n\n{notes}" if notes else "")
|
||||
)
|
||||
return
|
||||
|
||||
await msg.reply_photo(photo=png, caption=caption[:1024])
|
||||
except graphviz.ExecutableNotFound:
|
||||
logger.exception("Нет бинаря graphviz")
|
||||
await msg.reply_text(
|
||||
"На сервере не установлен Graphviz. Поставь его: `apt install graphviz` / "
|
||||
"`brew install graphviz` / `winget install graphviz`."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка при воспроизведении")
|
||||
await msg.reply_text(f"Не получилось воспроизвести: {e}")
|
||||
msg = "Это не похоже ни на график, ни на архитектурную схему."
|
||||
if notes:
|
||||
msg += f"\n{notes}"
|
||||
print(f"ℹ️ {msg}\n")
|
||||
|
||||
|
||||
# ---------- запуск ----------
|
||||
# ---------- главный цикл ----------
|
||||
|
||||
def main() -> None:
|
||||
app = Application.builder().token(TELEGRAM_TOKEN).build()
|
||||
app.add_handler(CommandHandler(["start", "help"], cmd_start))
|
||||
app.add_handler(CommandHandler("plot", cmd_plot))
|
||||
app.add_handler(CommandHandler("arch", cmd_arch))
|
||||
app.add_handler(CommandHandler("repro", cmd_repro))
|
||||
# фото без подписи-команды → описание (иначе команда уже обработана выше)
|
||||
app.add_handler(
|
||||
MessageHandler(filters.PHOTO & ~filters.CaptionRegex(r"^/"), on_photo)
|
||||
)
|
||||
logger.info("Бот запущен")
|
||||
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||
async def main() -> None:
|
||||
print("🤖 Qwen Console App")
|
||||
print_help()
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = input(">>> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nВыход.")
|
||||
break
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split(maxsplit=1)
|
||||
cmd = parts[0].lower()
|
||||
arg = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
try:
|
||||
if cmd in ("exit", "quit"):
|
||||
print("Выход.")
|
||||
break
|
||||
elif cmd == "help":
|
||||
print_help()
|
||||
elif cmd == "describe":
|
||||
await handle_describe(arg)
|
||||
elif cmd == "plot":
|
||||
await handle_plot(arg)
|
||||
elif cmd == "arch":
|
||||
await handle_arch(arg)
|
||||
elif cmd == "repro":
|
||||
await handle_repro(arg)
|
||||
else:
|
||||
print(f"Неизвестная команда: {cmd!r}. Введи help для справки.")
|
||||
except FileNotFoundError as e:
|
||||
print(f"❌ {e}")
|
||||
except graphviz.ExecutableNotFound:
|
||||
print("❌ Graphviz не установлен. Установи: apt install graphviz / brew install graphviz / winget install graphviz")
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
asyncio.run(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue