216 lines
6.3 KiB
Python
216 lines
6.3 KiB
Python
"""Журнал вызовов локального ИИ в админке (JSONL): ping, сводки, чат."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
_LOG = logging.getLogger(__name__)
|
|
_LOCK = threading.Lock()
|
|
|
|
_VALID_LEVELS = frozenset({"ok", "err", "warn"})
|
|
_VALID_EVENTS = frozenset(
|
|
{
|
|
"ping",
|
|
"summary",
|
|
"chat",
|
|
"diagnostics",
|
|
"settings",
|
|
"status",
|
|
}
|
|
)
|
|
|
|
|
|
def _llm_log_path(app) -> str:
|
|
return (app.config.get("WESP_ADMIN_LLM_LOG_PATH") or "").strip()
|
|
|
|
|
|
def _line_unix_ts(obj: Dict[str, Any]) -> Optional[float]:
|
|
u = obj.get("u")
|
|
if isinstance(u, (int, float)):
|
|
return float(u)
|
|
return None
|
|
|
|
|
|
def _retention_days(app) -> int:
|
|
return max(1, min(int(app.config.get("WESP_ADMIN_LLM_LOG_RETENTION_DAYS", 7) or 7), 30))
|
|
|
|
|
|
def _resolve_path(app, path_str: str) -> Path:
|
|
path = Path(path_str)
|
|
if not path.is_absolute():
|
|
path = Path(app.config.get("BASE_DIR", ".")) / path
|
|
return path
|
|
|
|
|
|
def append_admin_llm_activity(
|
|
app,
|
|
*,
|
|
event: str,
|
|
level: str = "ok",
|
|
user_login: str = "",
|
|
detail: str = "",
|
|
duration_ms: Optional[int] = None,
|
|
meta: Optional[Dict[str, Any]] = None,
|
|
) -> None:
|
|
"""Дописывает одну строку JSON; старые записи обрезаются по сроку хранения."""
|
|
try:
|
|
_append_admin_llm_activity_impl(
|
|
app,
|
|
event=event,
|
|
level=level,
|
|
user_login=user_login,
|
|
detail=detail,
|
|
duration_ms=duration_ms,
|
|
meta=meta,
|
|
)
|
|
except Exception:
|
|
_LOG.exception("Сбой журнала ИИ (WESP_ADMIN_LLM_LOG_PATH).")
|
|
|
|
|
|
def _append_admin_llm_activity_impl(
|
|
app,
|
|
*,
|
|
event: str,
|
|
level: str,
|
|
user_login: str,
|
|
detail: str,
|
|
duration_ms: Optional[int],
|
|
meta: Optional[Dict[str, Any]],
|
|
) -> None:
|
|
path_str = _llm_log_path(app)
|
|
if not path_str or app.config.get("TESTING"):
|
|
return
|
|
|
|
ev = str(event or "chat").strip().lower()
|
|
if ev not in _VALID_EVENTS:
|
|
ev = "chat"
|
|
lvl = str(level or "ok").strip().lower()
|
|
if lvl not in _VALID_LEVELS:
|
|
lvl = "ok"
|
|
|
|
row: Dict[str, Any] = {
|
|
"u": int(time.time()),
|
|
"event": ev,
|
|
"level": lvl,
|
|
"user": (user_login or "")[:128],
|
|
"detail": (detail or "")[:2000],
|
|
}
|
|
if isinstance(duration_ms, (int, float)) and duration_ms >= 0:
|
|
row["duration_ms"] = int(duration_ms)
|
|
if meta and isinstance(meta, dict):
|
|
slim: Dict[str, Any] = {}
|
|
for k, v in list(meta.items())[:24]:
|
|
key = str(k)[:64]
|
|
if isinstance(v, (str, int, float, bool)) or v is None:
|
|
slim[key] = v if v is None else (str(v)[:500] if isinstance(v, str) else v)
|
|
elif isinstance(v, (list, dict)):
|
|
try:
|
|
slim[key] = json.loads(json.dumps(v, ensure_ascii=False)[:2000])
|
|
except (TypeError, ValueError):
|
|
slim[key] = str(v)[:500]
|
|
if slim:
|
|
row["meta"] = slim
|
|
|
|
line = json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
path = _resolve_path(app, path_str)
|
|
cutoff = time.time() - float(_retention_days(app) * 86400)
|
|
|
|
with _LOCK:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
_rewrite_prune_and_append(path, cutoff, line)
|
|
|
|
|
|
def _rewrite_prune_and_append(path: Path, cutoff_ts: float, new_line: str) -> None:
|
|
lines_kept: List[str] = []
|
|
if path.is_file():
|
|
try:
|
|
with path.open("r", encoding="utf-8") as f:
|
|
for ln in f:
|
|
raw = ln.strip()
|
|
if not raw:
|
|
continue
|
|
try:
|
|
obj = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
ts = _line_unix_ts(obj)
|
|
if ts is not None and ts >= cutoff_ts:
|
|
lines_kept.append(raw + "\n")
|
|
except OSError:
|
|
lines_kept = []
|
|
|
|
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
|
|
try:
|
|
with tmp.open("w", encoding="utf-8") as out:
|
|
for ln in lines_kept:
|
|
out.write(ln)
|
|
out.write(new_line)
|
|
os.replace(str(tmp), str(path))
|
|
except OSError:
|
|
try:
|
|
if tmp.is_file():
|
|
tmp.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def format_llm_log_line_text(obj: Dict[str, Any]) -> str:
|
|
"""Одна строка для ленты активности и UI."""
|
|
ev = str(obj.get("event") or "llm")
|
|
detail = str(obj.get("detail") or "").strip()
|
|
dur = obj.get("duration_ms")
|
|
user = str(obj.get("user") or "").strip()
|
|
bits = [f"llm/{ev}"]
|
|
if detail:
|
|
bits.append(detail)
|
|
if isinstance(dur, (int, float)) and dur >= 0:
|
|
bits.append(f"{int(dur)} ms")
|
|
if user:
|
|
bits.append(f"· {user}")
|
|
return " ".join(bits)[:2000]
|
|
|
|
|
|
def read_admin_llm_activity(
|
|
app, *, max_lines: int = 200
|
|
) -> tuple[List[Dict[str, Any]], str]:
|
|
"""Последние записи за окно хранения (для API и ленты)."""
|
|
path_str = _llm_log_path(app)
|
|
if not path_str or app.config.get("TESTING"):
|
|
return [], ""
|
|
|
|
path = _resolve_path(app, path_str)
|
|
if not path.is_file():
|
|
return [], str(path)
|
|
|
|
cutoff = time.time() - float(_retention_days(app) * 86400)
|
|
cap = max(1, min(int(max_lines or 200), 500))
|
|
out: List[Dict[str, Any]] = []
|
|
|
|
try:
|
|
with path.open("r", encoding="utf-8") as f:
|
|
for ln in f:
|
|
raw = ln.strip()
|
|
if not raw:
|
|
continue
|
|
try:
|
|
obj = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if not isinstance(obj, dict):
|
|
continue
|
|
ts = _line_unix_ts(obj)
|
|
if ts is None or ts < cutoff:
|
|
continue
|
|
out.append(obj)
|
|
except OSError:
|
|
return [], str(path)
|
|
|
|
out.sort(key=lambda x: float(_line_unix_ts(x) or 0))
|
|
return out[-cap:], str(path)
|