232 lines
6.6 KiB
Python
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(" ", " ")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace("&", "&")
|
|
.replace(""", '"')
|
|
.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,
|
|
}
|