Files
siemens_ragas/webapp/services/profile_manager.py

138 lines
4.5 KiB
Python

"""In-memory + JSON-file LLM profile manager.
Profiles are kept in a dict keyed by profile_id and written to a JSON file
on every mutation, so they survive server restarts. The pattern mirrors
TaskManager but without threading concerns beyond a simple lock (profiles
are only mutated by API calls in FastAPI request handlers).
"""
from __future__ import annotations
import json
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from webapp.models import LLMProfile
_DEFAULT_STORE = Path(__file__).resolve().parents[2] / "configs" / "llm_profiles.json"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class ProfileManager:
"""Manages LLM profiles with in-memory cache and JSON file persistence."""
def __init__(self, store_path: Path = _DEFAULT_STORE) -> None:
self._store_path = store_path
self._lock = threading.Lock()
self._profiles: dict[str, LLMProfile] = {}
self._load()
# ------------------------------------------------------------------ #
# Public API
# ------------------------------------------------------------------ #
def list_all(self) -> list[LLMProfile]:
"""Return all profiles sorted by creation time."""
with self._lock:
return sorted(self._profiles.values(), key=lambda p: p.created_at)
def get(self, profile_id: str) -> LLMProfile | None:
"""Return one profile by id, or None if not found."""
with self._lock:
return self._profiles.get(profile_id)
def create(
self,
name: str,
model: str,
base_url: str,
api_key: str,
timeout_seconds: int = 30,
) -> LLMProfile:
"""Create and persist a new profile, returning it."""
now = _now_iso()
profile = LLMProfile(
profile_id=uuid.uuid4().hex[:12],
name=name,
model=model,
base_url=base_url,
api_key=api_key,
timeout_seconds=timeout_seconds,
created_at=now,
updated_at=now,
)
with self._lock:
self._profiles[profile.profile_id] = profile
self._persist()
return profile
def update(
self,
profile_id: str,
name: str,
model: str,
base_url: str,
api_key: str,
timeout_seconds: int = 30,
) -> LLMProfile | None:
"""Update an existing profile in-place; returns None if not found."""
with self._lock:
existing = self._profiles.get(profile_id)
if existing is None:
return None
updated = existing.model_copy(update={
"name": name,
"model": model,
"base_url": base_url,
"api_key": api_key,
"timeout_seconds": timeout_seconds,
"updated_at": _now_iso(),
})
self._profiles[profile_id] = updated
self._persist()
return updated
def delete(self, profile_id: str) -> bool:
"""Remove a profile; returns True if deleted, False if not found."""
with self._lock:
if profile_id not in self._profiles:
return False
del self._profiles[profile_id]
self._persist()
return True
# ------------------------------------------------------------------ #
# Persistence helpers
# ------------------------------------------------------------------ #
def _load(self) -> None:
"""Load profiles from the JSON store file, ignoring missing/corrupt files."""
if not self._store_path.exists():
return
try:
data = json.loads(self._store_path.read_text(encoding="utf-8"))
for raw in data.get("profiles", []):
p = LLMProfile.model_validate(raw)
self._profiles[p.profile_id] = p
except Exception: # noqa: BLE001
pass # Corrupt store — start fresh
def _persist(self) -> None:
"""Write current profiles to the JSON store file (called under lock)."""
self._store_path.parent.mkdir(parents=True, exist_ok=True)
payload = {"profiles": [p.model_dump() for p in self._profiles.values()]}
self._store_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# Module-level singleton shared by FastAPI routes.
profile_manager = ProfileManager()