uni-notify: Outlook + Teams → Telegram с LLM-фильтром

- 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
This commit is contained in:
Claude
2026-11-13 14:20:14 +01:00
commit da28d0ac1c
22 changed files with 1844 additions and 0 deletions

124
app/llm.py Normal file
View File

@@ -0,0 +1,124 @@
"""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()