ai: перевод абузных постов и саммари подборки (bot buttons, /sum, /tr, /api/summary, cache)
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user