Files
vpn-telegram-bot/services/ticket_ai.py
T

232 lines
6.6 KiB
Python

"""Автоответы ИИ по тикетам через OpenRouter."""
from __future__ import annotations
import html
import logging
import re
from typing import Any
from aiogram import Bot
from config import Settings
from services.db import Database
from services.faq import FAQ_ITEMS
from services.openrouter import (
AiDecision,
chat_completion,
load_openrouter_config,
parse_ai_decision,
)
from services.tickets import (
BODY_MAX,
clamp_body,
notify_user_ticket_reply,
)
logger = logging.getLogger(__name__)
_HTML_RE = re.compile(r"<[^>]+>")
def _strip_html(text: str) -> str:
t = _HTML_RE.sub("", text or "")
return (
t.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", '"')
.strip()
)
def build_faq_context() -> str:
lines = ["FAQ сервиса:"]
for item in FAQ_ITEMS:
lines.append(f"Q: {_strip_html(item.question)}")
lines.append(f"A: {_strip_html(item.answer)}")
lines.append("")
return "\n".join(lines).strip()
def build_ticket_messages_for_ai(messages: list[dict[str, Any]]) -> list[dict[str, str]]:
out: list[dict[str, str]] = []
for msg in messages[-20:]:
role = str(msg.get("author_role") or "")
body = str(msg.get("body") or "").strip()
if not body:
continue
if role == "user":
out.append({"role": "user", "content": body})
else:
prefix = "ИИ" if role == "ai" else "Поддержка"
out.append({"role": "assistant", "content": f"[{prefix}] {body}"})
return out
async def notify_admins_ai_escalate(
bot: Bot,
settings: Settings,
*,
ticket_id: int,
telegram_id: int,
subject: str,
reason: str,
) -> None:
from keyboards import admin_ticket_notify_inline
text = (
f"<b>👤 Нужен человек · тикет #{ticket_id}</b>\n"
f"От: <code>{telegram_id}</code>\n"
f"Тема: <b>{html.escape(subject)}</b>\n\n"
f"ИИ эскалировал: {html.escape((reason or 'без причины')[:400])}"
)
kb = admin_ticket_notify_inline(ticket_id)
for admin_id in settings.admin_ids:
try:
await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML")
except Exception as exc: # noqa: BLE001
logger.warning("notify admin escalate %s: %s", admin_id, exc)
async def try_ai_ticket_reply(
*,
db: Database,
bot: Bot | None,
settings: Settings,
ticket_id: int,
notify_user: bool = True,
) -> dict[str, Any]:
"""
Пытается ответить на тикет через OpenRouter.
Возвращает dict: ok, action, detail, model?
"""
cfg = load_openrouter_config(db)
if not cfg.ready or not cfg.auto_reply:
return {"ok": False, "action": "skip", "detail": "ai disabled"}
ticket = db.get_ticket(ticket_id)
if not ticket:
return {"ok": False, "action": "skip", "detail": "ticket not found"}
if str(ticket.get("status")) == "closed":
return {"ok": False, "action": "skip", "detail": "closed"}
messages = db.list_ticket_messages(ticket_id)
if not messages:
return {"ok": False, "action": "skip", "detail": "no messages"}
last = messages[-1]
if str(last.get("author_role")) != "user":
return {"ok": False, "action": "skip", "detail": "last not user"}
subject = str(ticket.get("subject") or "")
system = cfg.system_prompt.strip()
faq = build_faq_context()
system_full = (
f"{system}\n\n{faq}\n\n"
f"Текущий тикет #{ticket_id}, тема: {subject}"
)
chat_messages = [
{"role": "system", "content": system_full},
*build_ticket_messages_for_ai(messages),
]
referer = (settings.webapp_url or "").rstrip("/") or "https://bot.example.com"
try:
raw, used_model = await chat_completion(
api_key=cfg.api_key,
model=cfg.model,
models=cfg.models_for_request(),
messages=chat_messages,
referer=referer,
)
decision = parse_ai_decision(raw, used_model=used_model)
except Exception as exc: # noqa: BLE001
logger.exception("OpenRouter ticket #%s failed", ticket_id)
if bot is not None:
await notify_admins_ai_escalate(
bot,
settings,
ticket_id=ticket_id,
telegram_id=int(ticket["telegram_id"]),
subject=subject,
reason=f"Ошибка ИИ: {exc}",
)
return {"ok": False, "action": "error", "detail": str(exc)}
return await _apply_decision(
db=db,
bot=bot,
settings=settings,
ticket=ticket,
decision=decision,
notify_user=notify_user,
)
async def _apply_decision(
*,
db: Database,
bot: Bot | None,
settings: Settings,
ticket: dict[str, Any],
decision: AiDecision,
notify_user: bool,
) -> dict[str, Any]:
ticket_id = int(ticket["id"])
subject = str(ticket.get("subject") or "")
telegram_id = int(ticket["telegram_id"])
if decision.action == "answer" and decision.reply.strip():
reply = clamp_body(decision.reply)
if len(reply) > BODY_MAX:
reply = reply[: BODY_MAX - 1] + "…"
try:
db.add_ticket_message(
ticket_id,
author_role="ai",
author_id=None,
body=reply,
)
except ValueError as exc:
return {"ok": False, "action": "error", "detail": str(exc)}
if notify_user and bot is not None:
await notify_user_ticket_reply(
bot,
telegram_id=telegram_id,
ticket_id=ticket_id,
subject=subject,
body=reply,
is_ai=True,
)
logger.info(
"AI answered ticket #%s model=%s",
ticket_id,
decision.model or "?",
)
return {
"ok": True,
"action": "answer",
"detail": "ok",
"model": decision.model,
"reply": reply,
}
reason = decision.reason or "Нужен оператор"
if bot is not None:
await notify_admins_ai_escalate(
bot,
settings,
ticket_id=ticket_id,
telegram_id=telegram_id,
subject=subject,
reason=reason,
)
return {
"ok": True,
"action": "escalate",
"detail": reason,
"model": decision.model,
}