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:
124
app/llm.py
Normal file
124
app/llm.py
Normal 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()
|
||||
Reference in New Issue
Block a user