"""LLM через OpenRouter (OpenAI-совместимый API): оценка важности и ответы на вопросы.""" from __future__ import annotations import json import logging import re from datetime import datetime from zoneinfo import ZoneInfo import httpx from .db import Item log = logging.getLogger(__name__) TRIAGE_SYSTEM = """Ты — фильтр уведомлений для студента. На вход приходят события из университетской почты (Outlook) и Microsoft Teams. Событие бывает двух видов: - notification — системное уведомление клиента (title обычно отправитель/чат, text — текст сообщения); - row — карточка из списка писем/чатов, которая появилась или изменилась (строки: обычно отправитель или название чата, тема, превью, время). Для каждого события поставь уровень важности: 3 — срочно: дедлайн в ближайшие 48 часов, перенос/отмена занятия или экзамена на сегодня-завтра, преподаватель прямо просит ответить или что-то сделать, оценки/результаты, всё, что требует действия сегодня. 2 — важно: письма и сообщения от преподавателей, деканата, администрации; новые задания; изменения расписания; упоминание пользователя (@); личные сообщения от людей. 1 — в дайджест: общие рассылки университета, объявления без действия, обычная болтовня в групповых чатах без упоминания. 0 — игнор: спам, реклама, новостные рассылки, автоматические письма без пользы, собственные сообщения пользователя (начинаются с «Вы:», «You:», «Ty:» или от его имени), бессмысленные изменения карточек. Правила пользователя ниже важнее общих правил. Ответь ТОЛЬКО JSON-массивом, без пояснений: [{"i": <номер>, "level": <0-3>, "sender": "<кто>", "summary": "<1–2 предложения по-русски: кто, что, что нужно сделать и до когда>"}]""" ANSWER_SYSTEM = """Ты — личный ассистент студента. У тебя есть журнал событий из его университетской почты (Outlook) и Microsoft Teams. Отвечай по-русски, коротко и по делу, опираясь только на журнал ниже. Указывай, от кого и когда пришло. Если в журнале нет ответа — честно скажи, что не нашёл, и предложи, где посмотреть. Помни: журнал — это превью/уведомления, а не полные тексты писем. Сегодня: {now}.""" def _extract_json(text: str): text = text.strip() text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.M).strip() for opener, closer in (("[", "]"), ("{", "}")): a, b = text.find(opener), text.rfind(closer) if a != -1 and b > a: try: return json.loads(text[a : b + 1]) except json.JSONDecodeError: continue raise ValueError(f"LLM вернула не JSON: {text[:200]}") class LLM: def __init__(self, api_key: str, model: str, tz: str): self.model = model self.tz = ZoneInfo(tz) self.http = httpx.AsyncClient( base_url="https://openrouter.ai/api/v1", headers={ "Authorization": f"Bearer {api_key}", "HTTP-Referer": "https://github.com/Azimkin/uni-notify", "X-Title": "uni-notify", }, timeout=httpx.Timeout(60, connect=15), ) async def chat(self, system: str, messages: list[dict], max_tokens: int = 1200, temperature: float = 0.2) -> str: payload = { "model": self.model, "messages": [{"role": "system", "content": system}, *messages], "max_tokens": max_tokens, "temperature": temperature, } r = await self.http.post("/chat/completions", json=payload) if r.status_code >= 400: raise RuntimeError(f"OpenRouter {r.status_code}: {r.text[:300]}") data = r.json() return data["choices"][0]["message"]["content"] or "" async def triage(self, events: list[dict], rules: str, my_name: str) -> list[dict]: """events: [{source, kind, sender, title, body}] → [{i, level, sender, summary}]""" lines = [] for i, e in enumerate(events): body = (e.get("body") or "")[:1200] lines.append( f"#{i} [{e['source']}/{e['kind']}]\n" f"title: {e.get('title') or e.get('sender') or ''}\n" f"text: {body}" ) system = TRIAGE_SYSTEM if my_name: system += f"\n\nИмя пользователя: {my_name}." if rules.strip(): system += "\n\nПравила пользователя:\n" + rules.strip() raw = await self.chat(system, [{"role": "user", "content": "\n\n".join(lines)}], max_tokens=300 + 150 * len(events)) data = _extract_json(raw) if isinstance(data, dict): data = data.get("items") or data.get("result") or [data] out = [] for d in data: try: out.append({ "i": int(d["i"]), "level": max(0, min(3, int(d.get("level", 2)))), "sender": str(d.get("sender") or "").strip(), "summary": str(d.get("summary") or "").strip(), }) except (KeyError, TypeError, ValueError): continue return out def _fmt_item(self, it: Item) -> str: t = datetime.fromtimestamp(it.created_at, self.tz).strftime("%d.%m %H:%M") src = "Почта" if it.source == "mail" else "Teams" txt = it.summary or it.short(500) return f"[{t}, {src}] {it.sender or '?'}: {txt}" + (f"\n (оригинал: {it.short(400)})" if it.summary else "") async def answer(self, question: str, items: list[Item], history: list[dict]) -> str: now = datetime.now(self.tz).strftime("%A %d.%m.%Y %H:%M") journal = "\n".join(self._fmt_item(i) for i in items) or "(журнал пуст)" system = ANSWER_SYSTEM.format(now=now) + "\n\nЖурнал событий:\n" + journal msgs = [*history[-8:], {"role": "user", "content": question}] return (await self.chat(system, msgs, max_tokens=900, temperature=0.3)).strip() async def aclose(self): await self.http.aclose()