"""События из браузера → дедуп → правила → LLM-оценка → уведомление в Telegram.""" from __future__ import annotations import asyncio import hashlib import html import logging import re import time from collections import deque from datetime import datetime, timedelta from typing import Awaitable, Callable from zoneinfo import ZoneInfo from .config import Settings from .db import DB from .llm import LLM log = logging.getLogger(__name__) TIME_RE = re.compile( r"^(\d{1,2}[:.]\d{2}(\s?[AP]M)?|\d{1,2}[./]\d{1,2}([./]\d{2,4})?|\d+\s?\S{0,5}|now|teraz|сейчас)$", re.I ) ICON = {3: "🔴", 2: "🟡", 1: "⚪️", 0: "·"} SRC_NAME = {"mail": "📧 Почта", "teams": "💬 Teams"} BASELINE_SECONDS = 90 Say = Callable[..., Awaitable[None]] def norm(s: str) -> str: return re.sub(r"\s+", " ", s or "").strip().lower() def sha(s: str) -> str: return hashlib.sha1(s.encode()).hexdigest()[:16] INLINE_TIME_RE = re.compile(r"\b\d{1,2}[:.]\d{2}(\s?[AP]M)?\b", re.I) def stable_lines(lines: list[str]) -> list[str]: """Строки карточки без «тикающих» частей (время, «5 мин»).""" cand = [INLINE_TIME_RE.sub("", l).strip() for l in lines if not TIME_RE.match(l.strip())] cand = [l for l in cand if l] long = [l for l in cand if len(l) > 15] return long or cand class Pipeline: def __init__(self, s: Settings, db: DB, llm: LLM, say: Say): self.s = s self.db = db self.llm = llm self.say = say self.q: asyncio.Queue[dict] = asyncio.Queue() self.recent_texts: deque[tuple[float, str]] = deque(maxlen=300) self.baseline_until: dict[str, float] = {} self.tz = ZoneInfo(s.tz) # ---------- вход ---------- async def submit(self, ev: dict) -> None: await self.q.put(ev) def _rules(self) -> str: try: return self.s.rules_path.read_text(encoding="utf-8") except FileNotFoundError: return "" def _lists(self) -> tuple[list[str], list[str]]: vip = [*self.s.vip, *self.db.get("vip", [])] mute = [*self.s.mute, *self.db.get("mute", [])] return [v.lower() for v in vip], [m.lower() for m in mute] # ---------- нормализация ---------- def _to_item(self, ev: dict) -> dict | None: src = ev.get("source") if src not in ("mail", "teams"): return None kind = ev.get("kind") if kind == "notification": title, text = ev.get("title", ""), ev.get("text", "") if not (title or text): return None bucket = int(time.time() // 600) # одинаковые уведомления в разные 10-минутки — разные события return {"source": src, "kind": kind, "key": f"n:{sha(norm(title) + '|' + norm(text))}:{bucket}", "sender": title, "title": "", "body": text, "fp": norm(text)[:80]} if kind == "row": lines = [l for l in ev.get("lines", []) if l] if not lines and not ev.get("aria"): return None st = stable_lines(lines) or [ev.get("aria", "")] key = f"r:{ev.get('id', '')}:{sha('|'.join(norm(l) for l in st))}" sender = lines[0] if lines else "" title = lines[1] if src == "mail" and len(lines) > 1 else "" rest = [l for l in (lines[2:] if title else lines[1:]) if not TIME_RE.match(l)] body = " | ".join(rest) if ev.get("aria") and norm(ev["aria"]) not in norm(" ".join(lines)): body += f"\n[aria] {ev['aria']}" return {"source": src, "kind": kind, "key": key, "sender": sender, "title": title, "body": body, "fp": norm(" ".join(lines))} return None def _is_dup(self, it: dict) -> bool: now = time.time() while self.recent_texts and now - self.recent_texts[0][0] > 900: self.recent_texts.popleft() fp = it["fp"] for _, other in self.recent_texts: if not other or not fp: continue short, long_ = (fp, other) if len(fp) <= len(other) else (other, fp) if len(short) >= 12 and short[:60] in long_: return True return False def _in_baseline(self, source: str) -> bool: """Первый запуск: всё, что уже было в списках, запоминаем молча.""" if self.db.get(f"baseline:{source}"): return False until = self.baseline_until.get(source) if until is None: self.baseline_until[source] = time.time() + BASELINE_SECONDS log.info("Baseline для %s: %s сек молча запоминаю текущие списки", source, BASELINE_SECONDS) return True if time.time() < until: return True self.db.set(f"baseline:{source}", True) return False # ---------- основной цикл ---------- async def run(self) -> None: while True: first = await self.q.get() batch = [first] deadline = time.monotonic() + self.s.batch_window while len(batch) < 20: left = deadline - time.monotonic() if left <= 0: break try: batch.append(await asyncio.wait_for(self.q.get(), left)) except asyncio.TimeoutError: break try: await self.process(batch) except Exception: log.exception("Ошибка обработки пачки") async def process(self, events: list[dict]) -> None: # уведомления раньше карточек — тогда карточка того же сообщения отсеется как дубль events = sorted(events, key=lambda e: 0 if e.get("kind") == "notification" else 1) fresh: list[tuple[int, dict]] = [] for ev in events: it = self._to_item(ev) if not it: continue if it["kind"] == "row" and self._in_baseline(it["source"]): self.db.insert(it["source"], it["kind"], it["key"], it["sender"], it["title"], it["body"], status="baseline") continue dup = self._is_dup(it) item_id = self.db.insert(it["source"], it["kind"], it["key"], it["sender"], it["title"], it["body"], status="dup" if dup else "new") if item_id is None: continue # уже видели self.recent_texts.append((time.time(), it["fp"])) if not dup: fresh.append((item_id, it)) if not fresh: return vip, mute = self._lists() to_llm: list[tuple[int, dict]] = [] forced: dict[int, int] = {} for item_id, it in fresh: hay = norm(f"{it['sender']} {it['title']} {it['body']}") if any(m in hay for m in mute): self.db.update(item_id, level=0, status="skipped", summary="muted") continue if any(v in hay for v in vip): forced[item_id] = 3 to_llm.append((item_id, it)) if not to_llm: return try: verdicts = await self.llm.triage([it for _, it in to_llm], self._rules(), self.s.my_name) by_i = {v["i"]: v for v in verdicts} except Exception as e: log.warning("LLM недоступна (%s) — шлю без фильтра", e) by_i = {} for i, (item_id, it) in enumerate(to_llm): v = by_i.get(i, {"level": 2, "summary": "", "sender": ""}) level = max(v["level"], forced.get(item_id, 0)) summary = v.get("summary") or "" sender = v.get("sender") or it["sender"] if level >= self.s.notify_min: await self._notify(it, level, summary, sender) status = "sent" elif level >= 1: status = "digest" else: status = "skipped" self.db.update(item_id, level=level, summary=summary, status=status) # ---------- отправка ---------- def _quiet_now(self) -> bool: if not self.s.quiet_hours: return False a, b = self.s.quiet_hours h = datetime.now(self.tz).hour return (a <= h or h < b) if a > b else (a <= h < b) async def _notify(self, it: dict, level: int, summary: str, sender: str) -> None: e = html.escape head = f"{ICON.get(level, '')} {SRC_NAME.get(it['source'], it['source'])} · {e(sender[:80])}" orig = " — ".join(x for x in (it["title"], it["body"].split("\n[aria]")[0]) if x) if len(orig) > 600: orig = orig[:599] + "…" text = head if summary: text += f"\n{e(summary)}" if orig and norm(orig) != norm(summary): text += f"\n\n
{e(orig)}
" silent = level < 3 and self._quiet_now() await self.say(text, silent=silent) # ---------- дайджест ---------- async def send_digest(self, manual: bool = False) -> None: items = self.db.digest_pending() if not items: if manual: await self.say("Дайджест пуст — ничего неважного не накопилось.") return e = html.escape parts = [f"🗞 Дайджест ({len(items)})"] for src in ("mail", "teams"): group = [i for i in items if i.source == src] if not group: continue parts.append(f"\n{SRC_NAME[src]}") for it in group[-40:]: t = datetime.fromtimestamp(it.created_at, self.tz).strftime("%d.%m %H:%M") parts.append(f"• {t} {e((it.sender or '')[:40])}: {e(it.summary or it.short(150))}") await self.say("\n".join(parts), silent=True) for it in items: self.db.update(it.id, status="digested") async def digest_loop(self) -> None: if not self.s.digest_time: return hh, mm = (int(x) for x in self.s.digest_time.split(":")) while True: now = datetime.now(self.tz) target = now.replace(hour=hh, minute=mm, second=0, microsecond=0) if target <= now: target += timedelta(days=1) await asyncio.sleep(max(5, (target - now).total_seconds())) try: await self.send_digest() except Exception: log.exception("digest")