From 5d09deb420a2073f3eef69f9bd561f255f3c7bd9 Mon Sep 17 00:00:00 2001 From: wangwei Date: Tue, 16 Jun 2026 16:14:31 +0800 Subject: [PATCH] feat: add ProfileManager service with JSON persistence --- tests/webapp/test_profile_manager.py | 72 ++++++++++++++ webapp/services/profile_manager.py | 137 +++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 webapp/services/profile_manager.py diff --git a/tests/webapp/test_profile_manager.py b/tests/webapp/test_profile_manager.py index 39ece6a..a592823 100644 --- a/tests/webapp/test_profile_manager.py +++ b/tests/webapp/test_profile_manager.py @@ -26,3 +26,75 @@ def test_profile_apply_request_fields(): def test_profile_apply_response(): resp = ProfileApplyResponse(scenario_path="scenarios/offline/sample.yaml", patched_fields=["judge_model"]) assert "judge_model" in resp.patched_fields + + +# --------------------------------------------------------------------------- +# ProfileManager service tests +# --------------------------------------------------------------------------- +import json +from webapp.services.profile_manager import ProfileManager + + +def _make_manager(tmp_path): + store = tmp_path / "profiles.json" + return ProfileManager(store_path=store) + + +def test_create_profile(tmp_path): + mgr = _make_manager(tmp_path) + p = mgr.create(name="Local", model="deepseek-v4-flash", + base_url="http://localhost/v1", api_key="sk-x") + assert p.profile_id != "" + assert p.name == "Local" + + +def test_list_profiles(tmp_path): + mgr = _make_manager(tmp_path) + mgr.create(name="A", model="m1", base_url="http://a/v1", api_key="k1") + mgr.create(name="B", model="m2", base_url="http://b/v1", api_key="k2") + profiles = mgr.list_all() + assert len(profiles) == 2 + + +def test_get_profile(tmp_path): + mgr = _make_manager(tmp_path) + created = mgr.create(name="X", model="m", base_url="http://x/v1", api_key="k") + fetched = mgr.get(created.profile_id) + assert fetched is not None + assert fetched.name == "X" + + +def test_update_profile(tmp_path): + mgr = _make_manager(tmp_path) + p = mgr.create(name="Old", model="m", base_url="http://x/v1", api_key="k") + updated = mgr.update(p.profile_id, name="New", model="m2", + base_url="http://x/v1", api_key="k", timeout_seconds=60) + assert updated is not None + assert updated.name == "New" + assert updated.model == "m2" + assert updated.timeout_seconds == 60 + + +def test_delete_profile(tmp_path): + mgr = _make_manager(tmp_path) + p = mgr.create(name="Del", model="m", base_url="http://x/v1", api_key="k") + assert mgr.delete(p.profile_id) is True + assert mgr.get(p.profile_id) is None + + +def test_persistence(tmp_path): + store = tmp_path / "profiles.json" + mgr1 = ProfileManager(store_path=store) + p = mgr1.create(name="Persist", model="m", base_url="http://x/v1", api_key="k") + mgr2 = ProfileManager(store_path=store) + assert mgr2.get(p.profile_id) is not None + + +def test_get_nonexistent(tmp_path): + mgr = _make_manager(tmp_path) + assert mgr.get("does-not-exist") is None + + +def test_delete_nonexistent(tmp_path): + mgr = _make_manager(tmp_path) + assert mgr.delete("does-not-exist") is False diff --git a/webapp/services/profile_manager.py b/webapp/services/profile_manager.py new file mode 100644 index 0000000..9456029 --- /dev/null +++ b/webapp/services/profile_manager.py @@ -0,0 +1,137 @@ +"""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()