234 lines
8.2 KiB
Python
234 lines
8.2 KiB
Python
"""Runtime overrides for `skill_evolution` config, stored in skill_evolution.json.
|
|
|
|
Two scopes exist:
|
|
|
|
* **Global** (back-compat): ``backend/skill_evolution.json`` deep-merged onto
|
|
``config.yaml.skill_evolution`` at AppConfig load time. Used as a fallback
|
|
for users who never set a personal override.
|
|
* **Per-user**: ``.deer-flow/users/{user_id}/skill_evolution.json``. Read by
|
|
the curator on each run and by the ``GET/PUT /api/curator/config`` route
|
|
with the active user id. Curator merges this on top of the global config
|
|
via :func:`load_curator_config_for_user`.
|
|
|
|
The file is optional in both scopes; absence (or a malformed payload) is
|
|
treated as "no overrides" so the system keeps the YAML-only behaviour.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from deerflow.config.runtime_paths import existing_project_file
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_FILENAME = "skill_evolution.json"
|
|
|
|
|
|
def resolve_path() -> Path:
|
|
"""Resolve the on-disk location of ``skill_evolution.json``.
|
|
|
|
Priority:
|
|
1. ``DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH`` environment variable
|
|
2. ``skill_evolution.json`` in the project root (current and parents)
|
|
3. Legacy ``backend/`` or repo-root sibling locations
|
|
4. Falls back to ``backend/skill_evolution.json`` (does not need to exist)
|
|
"""
|
|
override = os.getenv("DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH")
|
|
if override:
|
|
return Path(override)
|
|
|
|
project_file = existing_project_file((_FILENAME,))
|
|
if project_file is not None:
|
|
return project_file
|
|
|
|
backend_dir = Path(__file__).resolve().parents[4]
|
|
repo_root = backend_dir.parent
|
|
for candidate in (backend_dir / _FILENAME, repo_root / _FILENAME):
|
|
if candidate.exists():
|
|
return candidate
|
|
|
|
return backend_dir / _FILENAME
|
|
|
|
|
|
def load_overrides() -> dict[str, Any] | None:
|
|
"""Read overrides from disk.
|
|
|
|
Returns ``None`` when the file does not exist, is empty, contains
|
|
non-dict JSON, or fails to parse. Errors are logged at WARNING level
|
|
so production stays silent in the common "no overrides" case.
|
|
"""
|
|
path = resolve_path()
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
raw = path.read_text(encoding="utf-8").strip()
|
|
if not raw:
|
|
return None
|
|
data = json.loads(raw)
|
|
except OSError as exc:
|
|
logger.warning("Failed to read %s: %s", path, exc)
|
|
return None
|
|
except json.JSONDecodeError as exc:
|
|
logger.warning("Skill evolution overrides at %s are not valid JSON: %s", path, exc)
|
|
return None
|
|
|
|
if not isinstance(data, dict):
|
|
logger.warning("Skill evolution overrides at %s must be a JSON object, got %s", path, type(data).__name__)
|
|
return None
|
|
return data
|
|
|
|
|
|
def save_overrides(payload: dict[str, Any]) -> Path:
|
|
"""Atomically persist ``payload`` to ``skill_evolution.json``.
|
|
|
|
The caller is responsible for shape validation (e.g. via the
|
|
``SkillEvolutionConfig`` Pydantic model). Returns the path that was
|
|
written so callers can log it.
|
|
"""
|
|
if not isinstance(payload, dict):
|
|
raise TypeError("skill_evolution overrides payload must be a dict")
|
|
|
|
path = resolve_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp_path_str = tempfile.mkstemp(prefix=".skill-evolution-", suffix=".tmp", dir=str(path.parent))
|
|
tmp_path = Path(tmp_path_str)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2, ensure_ascii=False)
|
|
os.replace(tmp_path, path)
|
|
except Exception:
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise
|
|
logger.info("Wrote skill_evolution overrides to %s", path)
|
|
return path
|
|
|
|
|
|
def get_overrides_mtime() -> float | None:
|
|
"""Return the mtime of ``skill_evolution.json`` if it exists, else None.
|
|
|
|
Used by the AppConfig cache to decide whether to reload — when the
|
|
file appears, disappears, or is modified, the mtime tuple changes
|
|
and ``get_app_config()`` rebuilds.
|
|
"""
|
|
path = resolve_path()
|
|
try:
|
|
return path.stat().st_mtime
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-user overrides
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def resolve_user_path(user_id: str) -> Path:
|
|
"""Return ``{base_dir}/users/{user_id}/skill_evolution.json``."""
|
|
from deerflow.config.paths import get_paths
|
|
|
|
return get_paths().user_dir(user_id) / _FILENAME
|
|
|
|
|
|
# Only these curator sub-fields may be overridden per user.
|
|
# All other fields (enabled, model_name, timeouts, etc.) are global-only.
|
|
_USER_ALLOWED_CURATOR_KEYS = {"interval_hours", "stale_after_days", "archive_after_days", "model_name"}
|
|
|
|
|
|
def load_user_overrides(user_id: str) -> dict[str, Any] | None:
|
|
"""Read the per-user overrides file for ``user_id`` (None if missing/invalid).
|
|
|
|
Only the three curator threshold keys are honoured; any other keys
|
|
(e.g. legacy ``enabled`` or ``model_name``) are silently stripped so that
|
|
old files written by previous versions cannot override global-only settings.
|
|
"""
|
|
path = resolve_user_path(user_id)
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
raw = path.read_text(encoding="utf-8").strip()
|
|
if not raw:
|
|
return None
|
|
data = json.loads(raw)
|
|
except OSError as exc:
|
|
logger.warning("Failed to read %s: %s", path, exc)
|
|
return None
|
|
except json.JSONDecodeError as exc:
|
|
logger.warning("User skill_evolution overrides at %s are not valid JSON: %s", path, exc)
|
|
return None
|
|
if not isinstance(data, dict):
|
|
logger.warning("User skill_evolution overrides at %s must be a JSON object", path)
|
|
return None
|
|
# Strip top-level keys that are not user-configurable (e.g. legacy "enabled").
|
|
curator_raw = data.get("curator")
|
|
if not isinstance(curator_raw, dict):
|
|
return None
|
|
filtered_curator = {k: v for k, v in curator_raw.items() if k in _USER_ALLOWED_CURATOR_KEYS}
|
|
if not filtered_curator:
|
|
return None
|
|
return {"curator": filtered_curator}
|
|
|
|
|
|
def save_user_overrides(user_id: str, payload: dict[str, Any]) -> Path:
|
|
"""Atomically persist ``payload`` to the per-user overrides file."""
|
|
if not isinstance(payload, dict):
|
|
raise TypeError("skill_evolution overrides payload must be a dict")
|
|
path = resolve_user_path(user_id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp_path_str = tempfile.mkstemp(prefix=".skill-evolution-", suffix=".tmp", dir=str(path.parent))
|
|
tmp_path = Path(tmp_path_str)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2, ensure_ascii=False)
|
|
os.replace(tmp_path, path)
|
|
except Exception:
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise
|
|
logger.info("Wrote per-user skill_evolution overrides to %s", path)
|
|
return path
|
|
|
|
|
|
def get_user_overrides_mtime(user_id: str) -> float | None:
|
|
path = resolve_user_path(user_id)
|
|
try:
|
|
return path.stat().st_mtime
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def load_skill_evolution_config_for_user(user_id: str | None):
|
|
"""Return a ``SkillEvolutionConfig`` instance with per-user overrides merged
|
|
on top of the global AppConfig defaults.
|
|
|
|
Used by the curator + the per-user ``GET/PUT /api/curator/config`` route.
|
|
When ``user_id`` is None or the per-user file is absent, returns the global
|
|
config unchanged.
|
|
"""
|
|
from deerflow.config import get_app_config
|
|
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
|
|
|
base = get_app_config().skill_evolution
|
|
if not user_id:
|
|
return base
|
|
overrides = load_user_overrides(user_id)
|
|
if not overrides:
|
|
return base
|
|
|
|
merged = base.model_dump()
|
|
for key, value in overrides.items():
|
|
existing = merged.get(key)
|
|
if isinstance(existing, dict) and isinstance(value, dict):
|
|
merged[key] = {**existing, **value}
|
|
else:
|
|
merged[key] = value
|
|
try:
|
|
return SkillEvolutionConfig.model_validate(merged)
|
|
except Exception:
|
|
logger.warning("Per-user skill_evolution overrides for %s failed validation, ignoring", user_id, exc_info=True)
|
|
return base
|