Files
uni-notify/app/llm.py
Claude da28d0ac1c 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
2026-11-13 14:20:14 +01:00

125 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()