- Playwright держит открытыми веб-версии Outlook и Teams (постоянный профиль, Xvfb) - watcher.js перехватывает уведомления клиента и изменения карточек в реальном времени - pipeline: дедуп, baseline, VIP/MUTE, оценка важности через Haiku (OpenRouter) - aiogram-бот: уведомления, дайджест, вопросы по журналу, /login с 2FA-кодом, ручное управление браузером - Docker/compose, systemd-юнит, e2e-тест на поддельных страницах Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194buWJjpDjU52JPX7PjsMs
125 lines
7.1 KiB
Python
125 lines
7.1 KiB
Python
"""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()
|