checking system…
Docs / back / src/maf/dashboard/routers/triggers.py · line 78
Python · 103 lines
  1"""Trigger library + validation endpoints.
  2
  3Split out of api.py — the library is data (loaded from YAML), the validator
  4is a thin wrapper around ``safe_eval``. Neither needs the rest of the
  5dashboard's machinery, so they live cleanly in their own module.
  6"""
  7
  8from __future__ import annotations
  9
 10import logging
 11from pathlib import Path
 12from typing import Any
 13
 14import yaml
 15from fastapi import APIRouter
 16from pydantic import BaseModel
 17
 18logger = logging.getLogger(__name__)
 19
 20router = APIRouter()
 21
 22
 23class TriggerValidateRequest(BaseModel):
 24    expr: str
 25    sample_payload: dict[str, Any] = {}
 26
 27
 28_LIBRARY_CACHE: dict[str, Any] = {"mtime": 0.0, "templates": None}
 29
 30
 31def _library_path() -> Path:
 32    """Resolve config/trigger_templates.yaml relative to the project root.
 33
 34    Falls back to the repo-shipped copy at the package's grandparent
 35    when the cwd-based lookup misses, so importing the module from a
 36    tools subdirectory still finds the file.
 37    """
 38    candidates = [
 39        Path("config/trigger_templates.yaml"),
 40        Path(__file__).resolve().parents[4] / "config" / "trigger_templates.yaml",
 41    ]
 42    for p in candidates:
 43        if p.exists():
 44            return p
 45    return candidates[0]
 46
 47
 48def _load_library() -> list[dict[str, Any]]:
 49    """Read config/trigger_templates.yaml, cached by mtime.
 50
 51    Hot-reload-friendly: editing the YAML on disk takes effect on the
 52    next GET without restarting the dashboard.
 53    """
 54    path = _library_path()
 55    try:
 56        mtime = path.stat().st_mtime
 57    except FileNotFoundError:
 58        return []
 59    if (
 60        _LIBRARY_CACHE["templates"] is not None
 61        and _LIBRARY_CACHE["mtime"] == mtime
 62    ):
 63        return _LIBRARY_CACHE["templates"]
 64    try:
 65        loaded = yaml.safe_load(path.read_text()) or []
 66        if not isinstance(loaded, list):
 67            logger.warning("trigger_templates.yaml: top-level must be a list, got %s", type(loaded))
 68            loaded = []
 69    except Exception as exc:
 70        logger.exception("failed to load trigger_templates.yaml: %s", exc)
 71        loaded = []
 72    _LIBRARY_CACHE["mtime"] = mtime
 73    _LIBRARY_CACHE["templates"] = loaded
 74    return loaded
 75
 76
 77@router.get("/api/triggers/library")
 78async def triggers_library() -> dict[str, Any]:
 79    """Predefined trigger rules the user can apply to any arena with one click.
 80
 81    Source of truth is ``config/trigger_templates.yaml`` — edit there to
 82    extend the picker, no Python changes needed. Reloads on mtime change.
 83    """
 84    return {"templates": _load_library()}
 85
 86
 87@router.post("/api/triggers/validate")
 88async def triggers_validate(req: TriggerValidateRequest) -> dict[str, Any]:
 89    """Validate a trigger ``when:`` expression with safe_eval.
 90
 91    Optionally evaluates against a user-supplied sample payload so the
 92    user can sanity-check their rule before saving. Returns
 93    ``{ok, error, result}`` — never raises.
 94    """
 95    from maf.triggers.safe_eval import SafeEvalError, safe_eval
 96    try:
 97        result = safe_eval(req.expr, {"payload": req.sample_payload or {}})
 98        return {"ok": True, "result": result, "error": None}
 99    except SafeEvalError as exc:
100        return {"ok": False, "result": None, "error": f"safe_eval: {exc}"}
101    except Exception as exc:
102        return {"ok": False, "result": None, "error": f"{type(exc).__name__}: {exc}"}