ai: перевод абузных постов и саммари подборки (bot buttons, /sum, /tr, /api/summary, cache)
This commit is contained in:
23
README.md
23
README.md
@@ -209,6 +209,29 @@ python -m omni.bot
|
|||||||
Команды: `/new [N]`, `/abuse [тег]`, `/find <слово>`, `/src <слово>`, `/run <категория>`, `/add <url>`, `/stats`. Бот работает на той же базе и том же реестре, что панель; отвечает только id из `OMNI_BOT_ALLOW`.
|
Команды: `/new [N]`, `/abuse [тег]`, `/find <слово>`, `/src <слово>`, `/run <категория>`, `/add <url>`, `/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 <id|слово>`. Перевод включается на чат/канал: в канале можно держать русский формат, в личке — оригинал.
|
||||||
|
|
||||||
|
**Автопостинг.** Если для канала включено «саммари в подписке», бот перед списком новых абузов пишет ИИ-сводку, дальше 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`.
|
1. **Python 3.11+** и зависимости: `pip install -r requirements.txt` (httpx, curl_cffi, bs4, lxml). Больше ничего не обязательно — база создаётся сама в `data/omni.sqlite3`.
|
||||||
|
|||||||
203
omni/ai.py
Normal file
203
omni/ai.py
Normal file
@@ -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
|
||||||
137
omni/bot.py
137
omni/bot.py
@@ -14,7 +14,7 @@
|
|||||||
/auto [n] автодобавление новых фидов
|
/auto [n] автодобавление новых фидов
|
||||||
/sub [@канал] подписать чат/канал на новые абузы
|
/sub [@канал] подписать чат/канал на новые абузы
|
||||||
/unsub [@канал] отписаться
|
/unsub [@канал] отписаться
|
||||||
/post [@канал] опубликовать абузы сейчас
|
/post [@канал] опубликовать абузы сейчас\n /sum [тег] ИИ-саммари подборки\n /tr <id|слово> перевод записи
|
||||||
/stats цифры
|
/stats цифры
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -28,6 +28,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from . import abuse as abuse_mod
|
from . import abuse as abuse_mod
|
||||||
|
from . import ai as ai_mod
|
||||||
from . import discover as discover_mod
|
from . import discover as discover_mod
|
||||||
from . import feeds, net, source_state, sources, store
|
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": "sub:on"}, {"text": "выкл", "callback_data": "sub:off"}],
|
||||||
[{"text": "Формат: кратко", "callback_data": "fmt:short"},
|
[{"text": "Формат: кратко", "callback_data": "fmt:short"},
|
||||||
{"text": "полно", "callback_data": "fmt:full"}],
|
{"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"}],
|
[{"text": "Пост в канал", "callback_data": "post:now"}],
|
||||||
]}
|
]}
|
||||||
|
|
||||||
@@ -275,8 +279,11 @@ def _scan_safe(limit=10, tag=None, window=6000):
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
def _line(it, fmt="short"):
|
def _line(it, fmt="short", tr=False):
|
||||||
title = _esc((it.get("title") or "")[:150])
|
raw = (it.get("title") or "")[:150]
|
||||||
|
if tr:
|
||||||
|
raw = ai_mod.translate(raw) or raw
|
||||||
|
title = _esc(raw)
|
||||||
src = _esc(it.get("source") or "")
|
src = _esc(it.get("source") or "")
|
||||||
tags = _esc(",".join(it.get("tags") or []))
|
tags = _esc(",".join(it.get("tags") or []))
|
||||||
head = "<b>%s</b>\n<code>%s</code> · %s" % (title, src, tags)
|
head = "<b>%s</b>\n<code>%s</code> · %s" % (title, src, tags)
|
||||||
@@ -287,17 +294,111 @@ def _line(it, fmt="short"):
|
|||||||
return head
|
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, "<b>саммари · %s · %d записей</b>\n\n%s" % (tag or "все", n, _esc(txt)), reply_markup=KB_MAIN)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_tr(chat_id, arg):
|
||||||
|
"""Перевод записи: /tr <id|слово>."""
|
||||||
|
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 = "<b>%s</b>\n<code>%s</code>\n\n%s" % (_esc(title[:150]), _esc(src), _esc(ru))
|
||||||
|
if url:
|
||||||
|
out += '\n<a href="%s">источник</a>' % _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)
|
d = _scan_safe(limit=limit, tag=tag)
|
||||||
if not d["items"]:
|
if not d["items"]:
|
||||||
return None
|
return None
|
||||||
head = "абузы · %s · %d" % (tag or "все", d["total"])
|
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):
|
def cmd_abuse(chat_id, arg):
|
||||||
tag = arg.strip().split()[0] if arg.strip() else None
|
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)
|
_send(chat_id, txt or "абузов не видно", reply_markup=KB_MAIN)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,7 +457,17 @@ def push_new(per_chat=5, window=2000):
|
|||||||
continue
|
continue
|
||||||
chunk = fresh[-per_chat:]
|
chunk = fresh[-per_chat:]
|
||||||
fmt = prefs_get(target) if isinstance(target, int) else "short"
|
fmt = prefs_get(target) if isinstance(target, int) else "short"
|
||||||
text = ("<b>новые абузы · %d</b>\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 = "<b>саммари</b>\n%s\n\n" % _esc(summary)
|
||||||
|
text = pre + ("<b>новые абузы · %d</b>\n\n" % len(fresh)) + "\n\n".join(_line(i, fmt, tr) for i in chunk)
|
||||||
try:
|
try:
|
||||||
_send(target, text[:MAX_LEN])
|
_send(target, text[:MAX_LEN])
|
||||||
sent += 1
|
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,
|
"/find": cmd_find, "/src": cmd_src, "/add": cmd_add, "/run": cmd_run,
|
||||||
"/auto": cmd_auto, "/stats": cmd_stats,
|
"/auto": cmd_auto, "/stats": cmd_stats,
|
||||||
"/sub": cmd_sub, "/unsub": cmd_unsub, "/subs": cmd_subs,
|
"/sub": cmd_sub, "/unsub": cmd_unsub, "/subs": cmd_subs,
|
||||||
"/post": cmd_test}
|
"/post": cmd_test, "/sum": cmd_sum, "/tr": cmd_tr}
|
||||||
|
|
||||||
|
|
||||||
def handle(update):
|
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)
|
return _send(chat_id, "формат: %s" % ("кратко" if data[4:] == "short" else "полно"), reply_markup=KB_MAIN)
|
||||||
if data == "post:now":
|
if data == "post:now":
|
||||||
return cmd_test(chat_id, "")
|
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():
|
def run_forever():
|
||||||
|
|||||||
@@ -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="")
|
_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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "omni/1.2"
|
server_version = "omni/1.2"
|
||||||
|
|
||||||
@@ -226,6 +240,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
lim = 40
|
lim = 40
|
||||||
return self._json(_ab.scan(limit=lim, tag=tag))
|
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":
|
if path == "/api/sources":
|
||||||
cat = (q.get("category") or [None])[0]
|
cat = (q.get("category") or [None])[0]
|
||||||
query = (q.get("q") or [""])[0].strip().lower()
|
query = (q.get("q") or [""])[0].strip().lower()
|
||||||
|
|||||||
@@ -332,6 +332,11 @@ select:focus,input:focus{border-color:rgba(124,108,244,.6)}
|
|||||||
<section id="view-abuse" style="display:none">
|
<section id="view-abuse" style="display:none">
|
||||||
<div class="strip" id="abuse-strip"></div>
|
<div class="strip" id="abuse-strip"></div>
|
||||||
<div class="filters" id="abuse-tags"></div>
|
<div class="filters" id="abuse-tags"></div>
|
||||||
|
<div class="filters" id="abuse-ai">
|
||||||
|
<button class="chip" id="btn-ai-sum">ИИ-саммари</button>
|
||||||
|
<span class="kv" id="ai-note">сводка по свежим находкам</span>
|
||||||
|
</div>
|
||||||
|
<div id="ai-sum" style="display:none;white-space:pre-wrap;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:14px 16px;margin:10px 0 16px;line-height:1.5"></div>
|
||||||
<div class="cards" id="abuse-cards"></div>
|
<div class="cards" id="abuse-cards"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -738,6 +743,25 @@ async function loadAbuse(tag) {
|
|||||||
</div>`).join('');
|
</div>`).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) => {
|
$('#btn-discover') && ($('#btn-discover').onclick = async (e) => {
|
||||||
const b = e.currentTarget; const t0 = b.textContent;
|
const b = e.currentTarget; const t0 = b.textContent;
|
||||||
b.disabled = true; b.textContent = 'ищу фиды…';
|
b.disabled = true; b.textContent = 'ищу фиды…';
|
||||||
|
|||||||
Reference in New Issue
Block a user