diff --git a/README.md b/README.md index 61dc6be..61442b2 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,29 @@ python -m omni.bot Команды: `/new [N]`, `/abuse [тег]`, `/find <слово>`, `/src <слово>`, `/run <категория>`, `/add `, `/stats`. Бот работает на той же базе и том же реестре, что панель; отвечает только id из `OMNI_BOT_ALLOW`. + +## ИИ: перевод и саммари + +Абузные площадки в основном не на русском: nodeloc (китайский), afflift и BHW (английский). Лента переводится на лету, подборка сворачивается в саммари — 3-5 пунктов, что раздают, платно или халява, где. + +**Подключение.** Любой OpenAI-совместимый эндпоинт: + +```bash +set OMNI_AI_KEY=<ключ> # обязателен только для ИИ-функций +set OMNI_AI_BASE=https://api.reformboss.com/v1 +set OMNI_AI_MODEL=gpt-6.1-sol # по умолчанию он же +``` + +Если `OMNI_AI_KEY` не задан, `omni/ai.py` ищет ключ в конфиге Hermes (`custom_providers` с `reformboss` в `base_url`). Без ключа парсер работает как раньше: ИИ-функции молча выключаются, кнопки пишут «ИИ не подключён». + +**В боте:** кнопки `ИИ-саммари`, `Перевод: вкл/выкл`, `Саммари в подписке: вкл/выкл`; команды `/sum [тег]`, `/tr `. Перевод включается на чат/канал: в канале можно держать русский формат, в личке — оригинал. + +**Автопостинг.** Если для канала включено «саммари в подписке», бот перед списком новых абузов пишет ИИ-сводку, дальше 5 находок с переводом заголовков. + +**В панели:** вкладка `Абузы` → кнопка `ИИ-саммари` (`GET /api/summary?tag=promo&limit=14`). + +**Как это сделано:** `omni/ai.py`, только stdlib (`urllib`), ретраи с падением на `glm-5.3` и `qwen3.8-flash`, если основная модель отдала 503/429. Переводы и саммари кэшируются в таблице `ai_cache` по хешу текста — повторные прогоны не тратят токены. Ключи не пишутся в репозиторий и не логируются. + ## Что нужно подключить 1. **Python 3.11+** и зависимости: `pip install -r requirements.txt` (httpx, curl_cffi, bs4, lxml). Больше ничего не обязательно — база создаётся сама в `data/omni.sqlite3`. diff --git a/omni/ai.py b/omni/ai.py new file mode 100644 index 0000000..9e89036 --- /dev/null +++ b/omni/ai.py @@ -0,0 +1,203 @@ +"""ИИ-слой: перевод абузных постов на русский и саммари подборки. + +Работает через любой OpenAI-совместимый эндпоинт. Ключ берётся из env +(OMNI_AI_KEY / OMNI_AI_BASE / OMNI_AI_MODEL) либо из конфига Hermes +(custom_providers с base_url, содержащим reformboss). Ключ никогда не пишется +в файлы репозитория и не логируется. + +Если ключа нет — функции возвращают None, вызывающий код молча падает на +исходный текст. Никаких обязательных зависимостей: только stdlib. +""" +from __future__ import annotations + +import hashlib +import json +import os +import sqlite3 +import time +import urllib.error +import urllib.request + +DEFAULT_BASE = "https://api.reformboss.com/v1" +DEFAULT_MODEL = "gpt-6.1-sol" +FALLBACK_MODELS = ("glm-5.3", "qwen3.8-flash") +TIMEOUT = 120 + +_CFG = {"base": None, "key": None, "model": None} + + +def _cfg_path(): + """Путь к конфигу Hermes. expanduser на MSYS иногда отдаёт '~', поэтому перебираем.""" + import getpass + env = os.environ.get("OMNI_HERMES_CFG") + if env and os.path.exists(env): + return env + cands = [] + try: + user = getpass.getuser() + except Exception: + user = os.environ.get("USERNAME") or "" + roots = [os.environ.get("USERPROFILE"), os.environ.get("HOME")] + if user: + roots.append(os.path.join("C:/Users", user)) + for root in roots: + if root and not root.startswith("~"): + cands.append(os.path.join(root.replace("\\", "/"), "AppData", "Local", "hermes", "config.yaml")) + try: + cands.append(os.path.join(os.path.expanduser("~"), "AppData", "Local", "hermes", "config.yaml")) + except Exception: + pass + for c in cands: + if os.path.exists(c): + return c + return cands[0] if cands else "" + + +def _hermes_cfg(): + p = _cfg_path() + try: + import yaml # есть в системе, но не обязателен + except Exception: + return None, None + try: + d = yaml.safe_load(open(p, encoding="utf-8")) or {} + except Exception as e: + if os.environ.get("OMNI_AI_DEBUG"): + print("ai cfg read fail:", type(e).__name__, e) + return None, None + for pr in d.get("custom_providers") or []: + base = str(pr.get("base_url") or "") + if "reformboss" in base and pr.get("api_key"): + return base.rstrip("/"), pr["api_key"] + return None, None + + +def cfg(): + if _CFG["base"]: + return _CFG + base = os.environ.get("OMNI_AI_BASE") + key = os.environ.get("OMNI_AI_KEY") + model = os.environ.get("OMNI_AI_MODEL") + if not (base and key): + hb, hk = _hermes_cfg() + base = base or hb + key = key or hk + _CFG["base"] = (base or DEFAULT_BASE).rstrip("/") + _CFG["key"] = key + _CFG["model"] = model or DEFAULT_MODEL + return _CFG + + +def available(): + return bool(cfg()["key"]) + + +def _once(model, messages, max_tokens, temperature): + c = cfg() + body = json.dumps({"model": model, "messages": messages, + "max_tokens": max_tokens, "temperature": temperature}).encode() + req = urllib.request.Request(c["base"] + "/chat/completions", data=body, + headers={"Authorization": "Bearer " + c["key"], + "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + d = json.loads(r.read().decode()) + return (d["choices"][0]["message"].get("content") or "").strip() + + +def chat(messages, model=None, max_tokens=700, temperature=0.2): + """Запрос с ретраями и сменой модели: 503/429 на одной модели не роняют перевод.""" + c = cfg() + if not c["key"]: + return None + chain = [model or c["model"]] + [m for m in FALLBACK_MODELS if m != (model or c["model"])] + for m in chain: + for attempt in range(2): + try: + out = _once(m, messages, max_tokens, temperature) + if out: + return out + except Exception: + time.sleep(1.5 + attempt) + return None + + +# --- кэш --------------------------------------------------------------------- +def _db(): + from .config import DB + c = sqlite3.connect(str(DB), timeout=30) + c.execute("CREATE TABLE IF NOT EXISTS ai_cache (k TEXT PRIMARY KEY, v TEXT, ts INTEGER)") + c.commit() + return c + + +def _cache_get(k): + try: + c = _db() + r = c.execute("SELECT v FROM ai_cache WHERE k=?", (k,)).fetchone() + c.close() + return r[0] if r else None + except Exception: + return None + + +def _cache_put(k, v): + try: + c = _db() + c.execute("INSERT OR REPLACE INTO ai_cache(k, v, ts) VALUES(?,?,?)", (k, v, int(time.time()))) + c.commit() + c.close() + except Exception: + pass + + +def translate(text, lang="ru"): + """Перевод короткого поста/заголовка. Возвращает строку или None.""" + text = (text or "").strip() + if not text or not available(): + return None + if _looks_ru(text): + return text + key = "tr:" + hashlib.sha1((lang + text).encode()).hexdigest() + hit = _cache_get(key) + if hit: + return hit + out = chat([ + {"role": "system", "content": + "Переводчик. Переведи текст на русский. Сохрани смысл, названия сервисов и ссылки " + "не переводи. Без пояснений, только перевод, максимум 200 символов."}, + {"role": "user", "content": text[:1200]}, + ], max_tokens=300) + if out: + out = out.strip().strip('"') + _cache_put(key, out) + return out or None + + +def summarize(posts, tag=None): + """Саммари подборки абузов: 3-5 коротких пунктов, что раздают и где.""" + posts = [p for p in (posts or []) if p][:12] + if not posts or not available(): + return None + key = "sum:" + hashlib.sha1(("|".join(posts[:12]) + str(tag)).encode()).hexdigest() + hit = _cache_get(key) + if hit: + return hit + body = "\n".join("- (%s) %s" % (p[:180], "") for p in posts) + out = chat([ + {"role": "system", "content": + "Ты аналитик. По списку постов с форумов сделай саммари на русском: 3-5 пунктов, " + "каждый до 120 символов, по делу — что предлагают, платно или халява, где. " + "Без вступлений, без воды, без markdown-заголовков."}, + {"role": "user", "content": body[:4000]}, + ], max_tokens=600) + if out: + _cache_put(key, out) + return out or None + + +def _looks_ru(text): + letters = [ch for ch in text if ch.isalpha()] + if not letters: + return True + ru = sum(1 for ch in letters if "а" <= ch.lower() <= "я" or ch.lower() in "ё") + return ru / len(letters) > 0.45 \ No newline at end of file diff --git a/omni/bot.py b/omni/bot.py index 6f01b7b..018b583 100644 --- a/omni/bot.py +++ b/omni/bot.py @@ -14,7 +14,7 @@ /auto [n] автодобавление новых фидов /sub [@канал] подписать чат/канал на новые абузы /unsub [@канал] отписаться - /post [@канал] опубликовать абузы сейчас + /post [@канал] опубликовать абузы сейчас\n /sum [тег] ИИ-саммари подборки\n /tr перевод записи /stats цифры """ from __future__ import annotations @@ -28,6 +28,7 @@ import urllib.parse import urllib.request from . import abuse as abuse_mod +from . import ai as ai_mod from . import discover as discover_mod from . import feeds, net, source_state, sources, store @@ -189,6 +190,9 @@ KB_MAIN = {"inline_keyboard": [ [{"text": "Подписка: вкл", "callback_data": "sub:on"}, {"text": "выкл", "callback_data": "sub:off"}], [{"text": "Формат: кратко", "callback_data": "fmt:short"}, {"text": "полно", "callback_data": "fmt:full"}], + [{"text": "ИИ-саммари", "callback_data": "ai:sum"}, + {"text": "Перевод: вкл/выкл", "callback_data": "ai:tr"}], + [{"text": "Саммари в подписке: вкл/выкл", "callback_data": "ai:sm"}], [{"text": "Пост в канал", "callback_data": "post:now"}], ]} @@ -275,8 +279,11 @@ def _scan_safe(limit=10, tag=None, window=6000): return d -def _line(it, fmt="short"): - title = _esc((it.get("title") or "")[:150]) +def _line(it, fmt="short", tr=False): + raw = (it.get("title") or "")[:150] + if tr: + raw = ai_mod.translate(raw) or raw + title = _esc(raw) src = _esc(it.get("source") or "") tags = _esc(",".join(it.get("tags") or [])) head = "%s\n%s · %s" % (title, src, tags) @@ -287,17 +294,111 @@ def _line(it, fmt="short"): return head -def abuse_text(tag=None, limit=6, fmt="short"): +AI_SQL = "CREATE TABLE IF NOT EXISTS bot_ai (chat_id INTEGER PRIMARY KEY, tr INTEGER DEFAULT 0, sm INTEGER DEFAULT 0)" + + +def ai_prefs(chat_id): + c = _conn() + c.execute(AI_SQL) + c.commit() + r = c.execute("SELECT tr, sm FROM bot_ai WHERE chat_id=?", (chat_id,)).fetchone() + c.close() + return (r[0], r[1]) if r else (0, 0) + + +def ai_set(chat_id, field, val): + c = _conn() + c.execute(AI_SQL) + c.execute("INSERT INTO bot_ai(chat_id, tr, sm) VALUES(?,0,0) ON CONFLICT(chat_id) DO NOTHING", (chat_id,)) + c.execute("UPDATE bot_ai SET %s=? WHERE chat_id=?" % ("tr" if field == "tr" else "sm"), (val, chat_id)) + c.commit() + c.close() + + +def ai_scan_summary(limit=12, tag=None): + """ИИ-саммари свежих абузов. None если ИИ недоступен.""" + d = _scan_safe(limit=limit, tag=tag) + items = d.get("items") or [] + if not items: + return None, 0 + posts = ["%s — %s" % (i.get("title") or "", i.get("source") or "") for i in items] + return ai_mod.summarize(posts, tag=tag), len(items) + + +def cmd_sum(chat_id, arg): + tag = arg.strip().split()[0] if arg.strip() else None + if not ai_mod.available(): + return _send(chat_id, "ИИ не подключён: задай OMNI_AI_KEY (или ключ reformboss в конфиге Hermes)", reply_markup=KB_MAIN) + txt, n = ai_scan_summary(limit=14, tag=tag) + if not txt: + return _send(chat_id, "саммари не собралось", reply_markup=KB_MAIN) + _send(chat_id, "саммари · %s · %d записей\n\n%s" % (tag or "все", n, _esc(txt)), reply_markup=KB_MAIN) + + +def cmd_tr(chat_id, arg): + """Перевод записи: /tr .""" + q = arg.strip() + if not q: + return _send(chat_id, "что перевести? /tr 4933 или /tr muse", reply_markup=KB_MAIN) + if not ai_mod.available(): + return _send(chat_id, "ИИ не подключён: задай OMNI_AI_KEY", reply_markup=KB_MAIN) + title = text = src = url = None + if q.isdigit(): + import sqlite3 + from .config import DB + c = sqlite3.connect(str(DB), timeout=20) + r = c.execute("SELECT title, text, source, url FROM items WHERE id=?", (int(q),)).fetchone() + c.close() + if r: + title, text, src, url = r[0], r[1] or "", r[2], r[3] + if title is None: + import sqlite3 + from .config import DB + c = sqlite3.connect(str(DB), timeout=20) + r = c.execute("SELECT title, text, source, url FROM items WHERE title LIKE ? " + "ORDER BY id DESC LIMIT 1", ("%" + q + "%",)).fetchone() + c.close() + if r: + title, text, src, url = r[0], r[1] or "", r[2], r[3] + if title is None: + rows = store.search(q, limit=1) + if rows: + r = rows[0] + if isinstance(r, dict): + title, src, url = r.get("title"), r.get("source"), r.get("url") + text = r.get("text") or "" + else: + src, title, url = r[2], r[3], r[5] + text = "" + if not title: + return _send(chat_id, "не нашёл запись", reply_markup=KB_MAIN) + payload = title if len(title) > 120 else (title + "\n" + (text or "")[:600]).strip() + ru = ai_mod.translate(payload) + if not ru: + return _send(chat_id, "перевод не получился, попробуй ещё раз", reply_markup=KB_MAIN) + out = "%s\n%s\n\n%s" % (_esc(title[:150]), _esc(src), _esc(ru)) + if url: + out += '\nисточник' % _esc(url) + _send(chat_id, out, reply_markup=KB_MAIN) + + +def abuse_text(tag=None, limit=6, fmt="short", tr=False): d = _scan_safe(limit=limit, tag=tag) if not d["items"]: return None head = "абузы · %s · %d" % (tag or "все", d["total"]) - return head + "\n\n" + "\n\n".join(_line(i, fmt) for i in d["items"]) + if tr: + head += " · перевод" + lines = [] + for n, i in enumerate(d["items"]): + lines.append(_line(i, fmt, tr and n < 5)) + return head + "\n\n" + "\n\n".join(lines) def cmd_abuse(chat_id, arg): tag = arg.strip().split()[0] if arg.strip() else None - txt = abuse_text(tag, fmt=prefs_get(chat_id)) + tr = bool(ai_prefs(chat_id)[0]) and ai_mod.available() + txt = abuse_text(tag, fmt=prefs_get(chat_id), tr=tr) _send(chat_id, txt or "абузов не видно", reply_markup=KB_MAIN) @@ -356,7 +457,17 @@ def push_new(per_chat=5, window=2000): continue chunk = fresh[-per_chat:] fmt = prefs_get(target) if isinstance(target, int) else "short" - text = ("новые абузы · %d\n\n" % len(fresh)) + "\n\n".join(_line(i, fmt) for i in chunk) + tr = sm = 0 + if isinstance(target, int): + tr, sm = ai_prefs(target) + tr = bool(tr) and ai_mod.available() + pre = "" + if sm and ai_mod.available(): + posts = ["%s — %s" % (i.get("title") or "", i.get("source") or "") for i in chunk] + summary = ai_mod.summarize(posts) + if summary: + pre = "саммари\n%s\n\n" % _esc(summary) + text = pre + ("новые абузы · %d\n\n" % len(fresh)) + "\n\n".join(_line(i, fmt, tr) for i in chunk) try: _send(target, text[:MAX_LEN]) sent += 1 @@ -382,7 +493,7 @@ CMDS = {"/start": cmd_start, "/help": cmd_start, "/new": cmd_new, "/abuse": cmd_ "/find": cmd_find, "/src": cmd_src, "/add": cmd_add, "/run": cmd_run, "/auto": cmd_auto, "/stats": cmd_stats, "/sub": cmd_sub, "/unsub": cmd_unsub, "/subs": cmd_subs, - "/post": cmd_test} + "/post": cmd_test, "/sum": cmd_sum, "/tr": cmd_tr} def handle(update): @@ -440,6 +551,16 @@ def handle_cb(cb): return _send(chat_id, "формат: %s" % ("кратко" if data[4:] == "short" else "полно"), reply_markup=KB_MAIN) if data == "post:now": return cmd_test(chat_id, "") + if data == "ai:sum": + return cmd_sum(chat_id, "") + if data == "ai:tr": + tr, _sm = ai_prefs(chat_id) + ai_set(chat_id, "tr", 0 if tr else 1) + return _send(chat_id, "перевод абузов: %s" % ("выкл" if tr else "вкл"), reply_markup=KB_MAIN) + if data == "ai:sm": + _tr, sm = ai_prefs(chat_id) + ai_set(chat_id, "sm", 0 if sm else 1) + return _send(chat_id, "саммари в подписке: %s" % ("выкл" if sm else "вкл"), reply_markup=KB_MAIN) def run_forever(): diff --git a/omni/serve.py b/omni/serve.py index 849cc54..0cc951a 100644 --- a/omni/serve.py +++ b/omni/serve.py @@ -142,6 +142,20 @@ def _run_check(job_id, names=None, category=None): _job(job_id, state="done", done=total, alive=alive, dead=dead, current="") +def _ai_summary(tag=None, limit=14): + """ИИ-саммари абузов для панели. None если ИИ не настроен.""" + from . import abuse as abuse_mod, ai as ai_mod + if not ai_mod.available(): + return {"ok": False, "error": "ai_not_configured"} + d = abuse_mod.scan(limit=limit, tag=tag, window=6000) + items = [i for i in d.get("items", []) if [t for t in (i.get("tags") or []) if t != "keys"]] + if not items: + return {"ok": True, "summary": "", "n": 0} + posts = ["%s — %s" % (i.get("title") or "", i.get("source") or "") for i in items] + txt = ai_mod.summarize(posts, tag=tag) + return {"ok": bool(txt), "summary": txt or "", "n": len(items)} + + class Handler(BaseHTTPRequestHandler): server_version = "omni/1.2" @@ -226,6 +240,19 @@ class Handler(BaseHTTPRequestHandler): lim = 40 return self._json(_ab.scan(limit=lim, tag=tag)) + if path == "/api/summary": + tag = (q.get("tag") or [""])[0].strip() or None + if tag in ("all", "все"): + tag = None + try: + lim = max(1, min(40, int((q.get("limit") or ["14"])[0]))) + except ValueError: + lim = 14 + try: + return self._json(_ai_summary(tag=tag, limit=lim)) + except Exception as e: + return self._json({"ok": False, "error": str(e)[:200]}) + if path == "/api/sources": cat = (q.get("category") or [None])[0] query = (q.get("q") or [""])[0].strip().lower() diff --git a/web/index.html b/web/index.html index 835c301..0bf3982 100644 --- a/web/index.html +++ b/web/index.html @@ -332,6 +332,11 @@ select:focus,input:focus{border-color:rgba(124,108,244,.6)} @@ -738,6 +743,25 @@ async function loadAbuse(tag) { `).join(''); } +$('#btn-ai-sum') && ($('#btn-ai-sum').onclick = async (e) => { + const b = e.currentTarget; const t0 = b.textContent; + const box = $('#ai-sum'); const note = $('#ai-note'); + b.disabled = true; b.textContent = 'думаю…'; + if (note) note.textContent = ''; + try { + const q = new URLSearchParams({limit: 14}); + if (S.abuseTag) q.set('tag', S.abuseTag); + const r = await api('/api/summary?' + q); + if (!r.ok) throw new Error(r.error === 'ai_not_configured' ? 'ИИ не настроен: OMNI_AI_KEY' : 'ошибка ИИ'); + box.style.display = ''; + box.textContent = r.summary || 'саммари пустое'; + if (note) note.textContent = 'записей ' + r.n; + } catch (err) { + box.style.display = ''; box.textContent = 'не вышло: ' + err.message; + } + b.disabled = false; b.textContent = t0; +}); + $('#btn-discover') && ($('#btn-discover').onclick = async (e) => { const b = e.currentTarget; const t0 = b.textContent; b.disabled = true; b.textContent = 'ищу фиды…';