@@ -0,0 +1,215 @@
|
||||
"""Журнал вызовов локального ИИ в админке (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)
|
||||
Reference in New Issue
Block a user