Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -0,0 +1,534 @@
"""Встроенные slim-tools для LLM-оркестратора: SQL (preview/execute), логи, сводка, классификация."""
from __future__ import annotations
import json
import re
import sqlite3
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from flask import Flask
from app.services.admin_dashboard_service import sqlite_bind_paths, tail_text_file
from app.services.client_log_upload import (
client_logs_root_resolved,
latest_uploaded_log_file,
safe_path_segment,
)
# ---------------------------------------------------------------------------
# OpenAI-style tool definitions (llama-server /v1/chat/completions)
# ---------------------------------------------------------------------------
SLIM_TOOL_DEFINITIONS: List[Dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "slim_sql",
"description": (
"Выполнить безопасный запрос к SQLite WESP (только SELECT). "
"Режим preview — план EXPLAIN без данных; execute — строки результата "
"(только если пользователь включил execute в запросе чата)."
),
"parameters": {
"type": "object",
"properties": {
"bind": {
"type": "string",
"description": "База: recipes или reports",
"enum": ["recipes", "reports"],
},
"sql": {"type": "string", "description": "Один оператор SELECT или WITH … SELECT"},
"mode": {
"type": "string",
"enum": ["preview", "execute"],
"description": "preview по умолчанию; execute только при явном разрешении в чате",
},
},
"required": ["bind", "sql"],
},
},
},
{
"type": "function",
"function": {
"name": "slim_extract",
"description": (
"Извлечь из лога ошибки, IP, HTTP-коды, метки времени, характерные строки."
),
"parameters": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["server_log", "client_log", "raw_text"],
"description": "server_log — WESP_ADMIN_LOG_PATH; client_log — выгрузки клиентов; raw_text — переданный текст",
},
"client_id": {
"type": "string",
"description": "Для client_log: node_id или сегмент каталога клиента",
},
"text": {
"type": "string",
"description": "Для raw_text: фрагмент лога (несколько тысяч символов)",
},
},
"required": ["source"],
},
},
},
{
"type": "function",
"function": {
"name": "slim_summary",
"description": "Сжать большой лог до 1–2 предложений (экстрактивно, без второго LLM).",
"parameters": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["server_log", "client_log", "raw_text"],
},
"client_id": {"type": "string"},
"text": {"type": "string"},
},
"required": ["source"],
},
},
},
{
"type": "function",
"function": {
"name": "slim_xsum",
"description": "Как slim_summary, но жёстче: максимум два коротких предложения.",
"parameters": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["server_log", "client_log", "raw_text"],
},
"client_id": {"type": "string"},
"text": {"type": "string"},
},
"required": ["source"],
},
},
},
{
"type": "function",
"function": {
"name": "slim_sentiment",
"description": (
"Оценить операционный тон лога: critical / warn / normal / backup_routine и уверенность 0..1."
),
"parameters": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["server_log", "client_log", "raw_text"],
},
"client_id": {"type": "string"},
"text": {"type": "string"},
},
"required": ["source"],
},
},
},
{
"type": "function",
"function": {
"name": "slim_emotions",
"description": (
"Грубая эмоциональная окраска событий в логе: stressed / calm / mixed (эвристика, не NLP-модель)."
),
"parameters": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["server_log", "client_log", "raw_text"],
},
"client_id": {"type": "string"},
"text": {"type": "string"},
},
"required": ["source"],
},
},
},
]
_IP_RE = re.compile(
r"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b"
)
_HTTP_CODE_RE = re.compile(r"\b(?:HTTP/\S+\s+)?(\d{3})\b|status[:\s=]+(\d{3})\b", re.I)
_ERR_HINT_RE = re.compile(
r"(error|exception|traceback|fatal|critical|failed|errno|segmentation|panic|"
r"ошибк|исключен|трассиров)\b",
re.I,
)
_BAD_SQL_KW = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH|DETACH|REPLACE|"
r"VACUUM|REINDEX|PRAGMA|TRUNCATE)\b",
re.I,
)
def _tool_result(
*,
ok: bool,
tool: str,
data: Any = None,
error: Optional[str] = None,
warnings: Optional[List[str]] = None,
truncated: bool = False,
) -> Dict[str, Any]:
return {
"ok": ok,
"tool": tool,
"data": data,
"error": error,
"warnings": warnings or [],
"truncated": truncated,
}
def _validate_select_only(sql: str) -> str:
s = (sql or "").strip()
if not s:
raise ValueError("Пустой SQL.")
if s.endswith(";"):
s = s[:-1].strip()
if ";" in s:
raise ValueError("Разрешён ровно один SQL-оператор (без «;» внутри).")
if not re.match(r"^\s*(SELECT|WITH)\b", s, re.I):
raise ValueError("Разрешены только SELECT или WITH … SELECT.")
if _BAD_SQL_KW.search(s):
raise ValueError("Запрещённые ключевые слова в SQL (только чтение).")
return s
def _open_sqlite_readonly(path: Path) -> sqlite3.Connection:
uri = path.resolve().as_uri() + "?mode=ro"
conn = sqlite3.connect(uri, uri=True, timeout=12.0)
conn.row_factory = sqlite3.Row
return conn
def _resolve_log_text(
app: Flask,
*,
source: str,
client_id: str = "",
raw_text: str = "",
max_bytes: int,
max_lines: int,
) -> Tuple[str, List[str]]:
warnings: List[str] = []
src = (source or "").strip().lower()
if src == "raw_text":
t = (raw_text or "").strip()
if not t:
return "", ["raw_text: пусто"]
if len(t.encode("utf-8", errors="replace")) > max_bytes:
t = t.encode("utf-8", errors="replace")[:max_bytes].decode("utf-8", errors="replace")
warnings.append("Текст обрезан по лимиту байт.")
return t, warnings
if src == "server_log":
raw = (app.config.get("WESP_ADMIN_LOG_PATH") or "").strip()
if not raw:
return "", ["WESP_ADMIN_LOG_PATH не задан."]
p = Path(raw).expanduser()
if not p.is_absolute():
p = Path(str(app.config.get("BASE_DIR") or ".")) / p
p = p.resolve()
lines, err = tail_text_file(p, max_lines=max_lines, max_bytes=max_bytes)
if err:
return "", [err]
return "\n".join(lines), warnings
if src == "client_log":
upload_dir = str(app.config.get("WESP_CLIENT_LOG_UPLOAD_DIR") or "").strip()
if not upload_dir:
return "", ["WESP_CLIENT_LOG_UPLOAD_DIR не задан."]
base_dir = str(app.config.get("BASE_DIR") or ".")
try:
root = client_logs_root_resolved(upload_dir, base_dir)
except ValueError as e:
return "", [str(e)]
cid = safe_path_segment(client_id, max_len=80) if client_id else ""
if not cid:
return "", ["Для client_log укажите client_id."]
d = root / cid
latest = latest_uploaded_log_file(d)
if latest is None:
return "", [f"Нет файлов лога в каталоге клиента «{cid}»."]
lines, err = tail_text_file(latest, max_lines=max_lines, max_bytes=max_bytes)
if err:
return "", [err]
return "\n".join(lines), warnings
return "", [f"Неизвестный source: {source!r}"]
def _extract_features(text: str) -> Dict[str, Any]:
lines = text.splitlines()
ip_set = set(_IP_RE.findall(text)) if text else set()
http_codes: List[str] = []
for m in _HTTP_CODE_RE.finditer(text or ""):
g = m.group(1) or m.group(2)
if g:
http_codes.append(g)
err_lines = [ln for ln in lines if _ERR_HINT_RE.search(ln)]
# топ «сигнатур» — первые уникальные короткие строки с error
sigs: List[str] = []
seen = set()
for ln in err_lines[:80]:
key = ln.strip()[:160]
if key and key not in seen:
seen.add(key)
sigs.append(key)
if len(sigs) >= 12:
break
return {
"line_count": len(lines),
"ipv4": sorted(ip_set)[:40],
"http_like_codes": http_codes[:40],
"error_line_samples": sigs,
}
def _summarize_text(text: str, *, strict_short: bool) -> str:
if not text.strip():
return "Лог пуст или не прочитан."
lines = text.splitlines()
head = lines[:15]
tail = lines[-25:] if len(lines) > 25 else []
err_lines = [ln for ln in lines if _ERR_HINT_RE.search(ln)]
pick: List[str] = []
for block in (err_lines[:20], tail, head):
for ln in block:
s = ln.strip()
if s and s not in pick:
pick.append(s)
if len(pick) >= 30:
break
if len(pick) >= 30:
break
blob = " ".join(pick)[:1200]
if strict_short:
return (
f"Кратко: всего строк ~{len(lines)}; ключевые фрагменты: {blob[:500]}"
+ ("" if len(blob) > 500 else "")
)
return (
f"В логе ~{len(lines)} строк. Сжато: {blob[:900]}"
+ ("" if len(blob) > 900 else "")
)
def _classify_operational(text: str) -> Dict[str, Any]:
t = (text or "").lower()
critical_hits = sum(
1 for w in ("fatal", "traceback", "segmentation", "panic", "critical", "emergency")
if w in t
)
err_hits = sum(1 for w in ("error", "exception", "failed", "errno") if w in t)
backup_hits = sum(
1 for w in ("backup", "rotat", "archive", "snapshot", "dump") if w in t
)
ok_hits = sum(1 for w in (" ok", "200 ", "success", "completed") if w in t)
label = "normal"
if critical_hits >= 1 or err_hits >= 5:
label = "critical"
elif err_hits >= 1:
label = "warn"
elif backup_hits >= 2 and err_hits == 0:
label = "backup_routine"
elif ok_hits >= 3 and err_hits == 0:
label = "normal"
# confidence heuristic
confidence = min(1.0, 0.35 + 0.1 * (critical_hits + err_hits + min(backup_hits, 3)))
return {"label": label, "confidence": round(confidence, 2), "hints": {"errors": err_hits, "critical": critical_hits, "backup": backup_hits}}
def _classify_emotions(text: str) -> Dict[str, Any]:
t = (text or "").lower()
stress = sum(
1
for w in ("error", "fatal", "panic", "failed", "critical", "alarm")
if w in t
)
calm = sum(1 for w in (" ok", "success", "ready", "listening", "started") if w in t)
if stress >= 3:
label = "stressed"
elif stress == 0 and calm >= 2:
label = "calm"
else:
label = "mixed"
return {"label": label, "confidence": round(min(1.0, 0.4 + 0.08 * (stress + calm)), 2)}
def run_slim_sql(
app: Flask,
args: Dict[str, Any],
*,
chat_sql_mode: str,
max_rows: int,
) -> Dict[str, Any]:
bind = str(args.get("bind") or "").strip().lower()
sql_raw = str(args.get("sql") or "")
mode = str(args.get("mode") or "preview").strip().lower()
if mode not in ("preview", "execute"):
mode = "preview"
if bind not in sqlite_bind_paths(app):
return _tool_result(ok=False, tool="slim_sql", error=f"Неизвестный bind «{bind}».")
try:
sql = _validate_select_only(sql_raw)
except ValueError as e:
return _tool_result(ok=False, tool="slim_sql", error=str(e))
paths = sqlite_bind_paths(app)
path = paths[bind]
if not path.is_file():
return _tool_result(ok=False, tool="slim_sql", error=f"Файл БД не найден: {path}")
if mode == "execute" and chat_sql_mode != "execute":
return _tool_result(
ok=False,
tool="slim_sql",
error="Режим execute отключён для этого запроса чата (выберите «Выполнять SELECT» в UI или sql_mode=execute).",
)
t0 = time.monotonic()
try:
conn = _open_sqlite_readonly(path)
except sqlite3.Error as e:
return _tool_result(ok=False, tool="slim_sql", error=f"SQLite: {e}")
warnings: List[str] = []
try:
if mode == "preview":
plan_cur = conn.execute(f"EXPLAIN QUERY PLAN {sql}")
plan_rows = [dict(row) for row in plan_cur.fetchall()]
return _tool_result(
ok=True,
tool="slim_sql",
data={
"bind": bind,
"mode": "preview",
"plan": plan_rows[:200],
"sql_echo": sql[:2000],
"elapsed_sec": round(time.monotonic() - t0, 4),
},
warnings=warnings,
)
# execute: читаем не более max_rows строк (остальное отбрасываем; тяжёлый запрос всё равно может грузить СУБД)
cur = conn.execute(sql)
colnames = [d[0] for d in cur.description] if cur.description else []
out_rows: List[sqlite3.Row] = []
truncated = False
for i, row in enumerate(cur):
if i >= max_rows:
truncated = True
break
out_rows.append(row)
serialized = []
for r in out_rows:
serialized.append({colnames[i]: r[i] for i in range(len(colnames))})
if truncated:
warnings.append(f"Строк больше лимита ({max_rows}); результат обрезан.")
return _tool_result(
ok=True,
tool="slim_sql",
data={
"bind": bind,
"mode": "execute",
"columns": colnames,
"rows": serialized,
"row_count": len(serialized),
"elapsed_sec": round(time.monotonic() - t0, 4),
},
warnings=warnings,
truncated=truncated,
)
except sqlite3.Error as e:
return _tool_result(ok=False, tool="slim_sql", error=f"SQLite: {e}")
finally:
try:
conn.close()
except Exception:
pass
def dispatch_slim_tool(
app: Flask,
name: str,
raw_arguments: str,
*,
chat_sql_mode: str,
max_rows: int,
max_log_bytes: int,
max_log_lines: int,
) -> Dict[str, Any]:
"""Парсит arguments JSON и вызывает обработчик."""
try:
args = json.loads(raw_arguments or "{}")
except json.JSONDecodeError as e:
return _tool_result(ok=False, tool=name, error=f"Некорректный JSON аргументов: {e}")
if not isinstance(args, dict):
return _tool_result(ok=False, tool=name, error="Аргументы инструмента должны быть объектом JSON.")
if name == "slim_sql":
return run_slim_sql(app, args, chat_sql_mode=chat_sql_mode, max_rows=max_rows)
if name in ("slim_extract", "slim_summary", "slim_xsum", "slim_sentiment", "slim_emotions"):
source = str(args.get("source") or "")
client_id = str(args.get("client_id") or "")
raw_text = str(args.get("text") or "")
text, w = _resolve_log_text(
app,
source=source,
client_id=client_id,
raw_text=raw_text,
max_bytes=max_log_bytes,
max_lines=max_log_lines,
)
if not text and w:
return _tool_result(ok=False, tool=name, error="; ".join(w))
if name == "slim_extract":
feat = _extract_features(text)
truncated = any("обрезан" in str(x) for x in w)
return _tool_result(
ok=True,
tool=name,
data=feat,
warnings=w,
truncated=truncated,
)
if name == "slim_summary":
summary = _summarize_text(text, strict_short=False)
return _tool_result(ok=True, tool=name, data={"summary_ru": summary}, warnings=w)
if name == "slim_xsum":
summary = _summarize_text(text, strict_short=True)
return _tool_result(ok=True, tool=name, data={"summary_ru": summary}, warnings=w)
if name == "slim_sentiment":
cl = _classify_operational(text)
return _tool_result(ok=True, tool=name, data=cl, warnings=w)
if name == "slim_emotions":
em = _classify_emotions(text)
return _tool_result(ok=True, tool=name, data=em, warnings=w)
return _tool_result(ok=False, tool=name, error=f"Неизвестный инструмент: {name}")