"""Автоответы ИИ по тикетам через 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"👤 Нужен человек · тикет #{ticket_id}\n" f"От: {telegram_id}\n" f"Тема: {html.escape(subject)}\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, }