"""Журнал вызовов локального ИИ в админке (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)