From 0d455de386157c3887ed23175c96918432118e3c Mon Sep 17 00:00:00 2001 From: ArokyaMatthew Date: Tue, 9 Jun 2026 11:22:08 +0530 Subject: [PATCH] feat(config): centralise all config via pydantic-settings env loader (#32) --- .env.example | 45 ++++++--- libs/config/settings.py | 67 +++++++------- services/memory/action_bridge.py | 3 +- services/memory/memory.py | 9 +- services/memory/ring_buffer.py | 3 +- tests/test_settings.py | 152 +++++++++++++++++++++++++++++++ 6 files changed, 229 insertions(+), 50 deletions(-) create mode 100644 tests/test_settings.py diff --git a/.env.example b/.env.example index 137c0ba..4008dfc 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,47 @@ # ── Eagle Environment Configuration ────────────────────────────────────── # Copy this file to .env and fill in the values before running the project. +# All variables map 1:1 to fields in libs/config/settings.py (Settings class). -# Redis (used for temporal memory ring buffer) -REDIS_URL=redis://localhost:6379/0 +# ── Redis ───────────────────────────────────────────────────────────────── +REDIS_URL=redis://localhost:6379 -# Ollama (local VLM inference) -OLLAMA_HOST=http://localhost:11434 +# ── Memory / ring-buffer ───────────────────────────────────────────────── +MAX_EVENTS_PER_TRACK=50 +TRACK_TTL_SECONDS=86400 + +# ── Action classifier thresholds ───────────────────────────────────────── +LINGERING_THRESHOLD_SEC=5.0 +MOVEMENT_THRESHOLD_PX=8.0 +NEAR_KEYPAD_DIST_PX=80.0 +KEYPAD_CENTER_X=600.0 +KEYPAD_CENTER_Y=280.0 -# Detection +# ── Detection ──────────────────────────────────────────────────────────── YOLO_MODEL=yolov8n.pt DETECTION_CONFIDENCE=0.4 DETECTION_CONFIDENCE_THRESHOLD=0.45 -# Policy -POLICY_PATH=policies/default.yaml +# ── Tracker ────────────────────────────────────────────────────────────── +TRACKER_MAX_AGE=30 +TRACKER_N_INIT=3 -# Backend -API_HOST=0.0.0.0 -API_PORT=8000 - -# VLM / LLM provider +# ── VLM / LLM provider ────────────────────────────────────────────────── # Use "mock" for CI, offline development, or contributors without a GPU. # No Ollama installation required when set to "mock". # Future values: "ollama", "openai", "gemini" VLM_PROVIDER=mock +LLM_PROVIDER=mock +OLLAMA_HOST=http://localhost:11434 + +# ── Reasoning / alerts ─────────────────────────────────────────────────── +REASONING_DWELL_THRESHOLD_SECONDS=5.0 +REASONING_COOLDOWN_SECONDS=5.0 +RING_BUFFER_MAX=50 +ALERT_DEDUP_WINDOW=300 + +# ── Backend / API ──────────────────────────────────────────────────────── +API_HOST=0.0.0.0 +API_PORT=8000 + +# ── Policy ─────────────────────────────────────────────────────────────── +POLICY_PATH=policies/default.yaml diff --git a/libs/config/settings.py b/libs/config/settings.py index 71df782..3271836 100644 --- a/libs/config/settings.py +++ b/libs/config/settings.py @@ -4,61 +4,64 @@ class Settings(BaseSettings): - # Environment-backed connection / API settings + """Centralised configuration for the Eagle surveillance system. + + Every field can be overridden via an environment variable of the same + (uppercased) name or via a ``.env`` file in the project root. + """ + + # ── Redis ───────────────────────────────────────────────────────────── redis_url: str = "redis://localhost:6379" - vlm_provider: str = "mock" - llm_provider: str = "mock" - ollama_host: str = "http://localhost:11434" - # YOLO / detection settings (kept for backward compatibility alongside existing names) - yolo_model: str = "yolov8n.pt" - detection_confidence: float = 0.4 - api_host: str = "0.0.0.0" - api_port: int = 8000 + # ── Memory / ring-buffer ────────────────────────────────────────────── + max_events_per_track: int = 50 + track_ttl_seconds: int = 86_400 # 24 h - # Action classifier thresholds + # ── Action classifier thresholds ────────────────────────────────────── lingering_threshold_sec: float = 5.0 - movement_threshold_px: float = 10.0 + movement_threshold_px: float = 8.0 near_keypad_dist_px: float = 80.0 - keypad_center_x: int = 320 - keypad_center_y: int = 240 - policy_path: str = "policies/default.yaml" + keypad_center_x: float = 600.0 + keypad_center_y: float = 280.0 + + # ── Detection ───────────────────────────────────────────────────────── + yolo_model: str = "yolov8n.pt" detector_model: str = "yolov8n.pt" + detection_confidence: float = 0.4 detection_confidence_threshold: float = 0.45 detector_device: str = "cpu" + confidence_threshold: float = 0.45 + + # ── Tracker ─────────────────────────────────────────────────────────── tracker_fps: float = 30 tracker_max_age: int = 30 tracker_n_init: int = 3 tracker_max_cosine_distance: float = 0.4 - camera_id: str = "cam_01" - # Action classifier settings - lingering_threshold_sec: float = 5.0 - movement_threshold_px: float = 15.0 - near_keypad_dist_px: float = 75.0 - keypad_center_x: float = 500.0 - keypad_center_y: float = 500.0 + # ── VLM / LLM providers ────────────────────────────────────────────── + vlm_provider: str = "mock" + llm_provider: str = "mock" + ollama_host: str = "http://localhost:11434" - # Reasoning trigger settings + # ── Reasoning / alerts ──────────────────────────────────────────────── reasoning_dwell_threshold_seconds: float = 5.0 reasoning_cooldown_seconds: float = 5.0 - - # New reasoning / alert settings reasoning_trigger_sec: float = 5.0 ring_buffer_max: int = 50 alert_dedup_window: int = 300 - snapshot_dir: str = "/tmp/eagle_snapshots" + + # ── Backend / API ───────────────────────────────────────────────────── + api_host: str = "0.0.0.0" + api_port: int = 8000 cors_origins: list[str] = ["http://localhost:5173"] # Vite dev max_alerts_page: int = 50 + snapshot_dir: str = "/tmp/eagle_snapshots" - # Action classifier settings - lingering_threshold_sec: float = 10.0 - movement_threshold_px: float = 5.0 - near_keypad_dist_px: float = 80.0 - keypad_center_x: float = 640.0 - keypad_center_y: float = 360.0 + # ── Policy ──────────────────────────────────────────────────────────── + policy_path: str = "policies/default.yaml" + camera_id: str = "cam_01" - # Kafka Settings + # ── Kafka ───────────────────────────────────────────────────────────── use_kafka: bool = False kafka_bootstrap_servers: str = "localhost:9092" kafka_topic: str = "track-events" diff --git a/services/memory/action_bridge.py b/services/memory/action_bridge.py index f7f6144..cbd537a 100644 --- a/services/memory/action_bridge.py +++ b/services/memory/action_bridge.py @@ -9,13 +9,14 @@ from libs.schemas.action_recognition import ActionFrameResult, ActionPrediction from libs.schemas.memory import ActionHint, TrackEvent +from libs.config.settings import settings if TYPE_CHECKING: from services.memory.memory import MemoryService logger = logging.getLogger(__name__) -TRACK_TTL_SECONDS = 86_400 +TRACK_TTL_SECONDS = settings.track_ttl_seconds def _prediction_to_hint(pred: ActionPrediction) -> ActionHint: diff --git a/services/memory/memory.py b/services/memory/memory.py index ee681df..fdb0bc4 100644 --- a/services/memory/memory.py +++ b/services/memory/memory.py @@ -38,13 +38,14 @@ from libs.observability.metrics import redis_write_latency from libs.schemas.tracking import TrackLifecycleEvent, TrackState from libs.schemas.memory import TrackEvent, TrackSequence, ActionHint +from libs.config.settings import settings from services.tracking.cross_camera_reid import CrossCameraReID logger = logging.getLogger(__name__) -# ── Redis TTLs ──────────────────────────────────────────────────────────────── -TRACK_TTL_SECONDS = 86_400 # 24 h — keep per-track state for a full day -EVENT_TTL_SECONDS = 86_400 +# ── Redis TTLs (sourced from centralised settings) ─────────────────────────── +TRACK_TTL_SECONDS = settings.track_ttl_seconds +EVENT_TTL_SECONDS = settings.track_ttl_seconds class MemoryService: @@ -353,7 +354,7 @@ def _append_event( # Compatibility layer: lightweight event store used by tests and the pipeline. -MAX_EVENTS_PER_TRACK = 50 +MAX_EVENTS_PER_TRACK = settings.max_events_per_track class MemoryStore: diff --git a/services/memory/ring_buffer.py b/services/memory/ring_buffer.py index ec8bac4..c97097e 100644 --- a/services/memory/ring_buffer.py +++ b/services/memory/ring_buffer.py @@ -3,8 +3,9 @@ from typing import Optional from libs.schemas.memory import TrackEvent, TrackSequence +from libs.config.settings import settings -MAX_EVENTS_PER_TRACK = 50 +MAX_EVENTS_PER_TRACK = settings.max_events_per_track class MemoryStore: diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..415c31f --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,152 @@ +""" +Unit tests for libs/config/settings.py (issue #32). + +Verifies: + - Default values match the documented contract. + - Environment variables override defaults correctly. + - The singleton ``settings`` object is importable and valid. +""" +from __future__ import annotations + +import os +import pytest + +from libs.config.settings import Settings + + +# ── Default values ──────────────────────────────────────────────────────────── + +class TestDefaults: + """Verify all defaults match the values specified in the issue.""" + + def test_redis_url(self): + s = Settings() + assert s.redis_url == "redis://localhost:6379" + + def test_max_events_per_track(self): + s = Settings() + assert s.max_events_per_track == 50 + + def test_track_ttl_seconds(self): + s = Settings() + assert s.track_ttl_seconds == 86_400 + + def test_lingering_threshold_sec(self): + s = Settings() + assert s.lingering_threshold_sec == 5.0 + + def test_movement_threshold_px(self): + s = Settings() + assert s.movement_threshold_px == 8.0 + + def test_near_keypad_dist_px(self): + s = Settings() + assert s.near_keypad_dist_px == 80.0 + + def test_keypad_center_x(self): + s = Settings() + assert s.keypad_center_x == 600.0 + + def test_keypad_center_y(self): + s = Settings() + assert s.keypad_center_y == 280.0 + + def test_yolo_model(self): + s = Settings() + assert s.yolo_model == "yolov8n.pt" + + def test_confidence_threshold(self): + s = Settings() + assert s.confidence_threshold == 0.45 + + def test_tracker_max_age(self): + s = Settings() + assert s.tracker_max_age == 30 + + def test_tracker_n_init(self): + s = Settings() + assert s.tracker_n_init == 3 + + def test_vlm_provider_default_is_mock(self): + s = Settings() + assert s.vlm_provider == "mock" + + +# ── Environment overrides ──────────────────────────────────────────────────── + +class TestEnvOverrides: + """Verify that environment variables correctly override defaults.""" + + def test_redis_url_override(self, monkeypatch): + monkeypatch.setenv("REDIS_URL", "redis://prod:6380/1") + s = Settings() + assert s.redis_url == "redis://prod:6380/1" + + def test_max_events_per_track_override(self, monkeypatch): + monkeypatch.setenv("MAX_EVENTS_PER_TRACK", "100") + s = Settings() + assert s.max_events_per_track == 100 + + def test_track_ttl_seconds_override(self, monkeypatch): + monkeypatch.setenv("TRACK_TTL_SECONDS", "3600") + s = Settings() + assert s.track_ttl_seconds == 3600 + + def test_lingering_threshold_override(self, monkeypatch): + monkeypatch.setenv("LINGERING_THRESHOLD_SEC", "10.0") + s = Settings() + assert s.lingering_threshold_sec == 10.0 + + def test_movement_threshold_override(self, monkeypatch): + monkeypatch.setenv("MOVEMENT_THRESHOLD_PX", "20.0") + s = Settings() + assert s.movement_threshold_px == 20.0 + + def test_keypad_center_override(self, monkeypatch): + monkeypatch.setenv("KEYPAD_CENTER_X", "400.0") + monkeypatch.setenv("KEYPAD_CENTER_Y", "300.0") + s = Settings() + assert s.keypad_center_x == 400.0 + assert s.keypad_center_y == 300.0 + + def test_yolo_model_override(self, monkeypatch): + monkeypatch.setenv("YOLO_MODEL", "yolov8s.pt") + s = Settings() + assert s.yolo_model == "yolov8s.pt" + + def test_confidence_threshold_override(self, monkeypatch): + monkeypatch.setenv("CONFIDENCE_THRESHOLD", "0.60") + s = Settings() + assert s.confidence_threshold == 0.60 + + def test_tracker_max_age_override(self, monkeypatch): + monkeypatch.setenv("TRACKER_MAX_AGE", "60") + s = Settings() + assert s.tracker_max_age == 60 + + def test_vlm_provider_override(self, monkeypatch): + monkeypatch.setenv("VLM_PROVIDER", "ollama") + s = Settings() + assert s.vlm_provider == "ollama" + + def test_api_port_override(self, monkeypatch): + monkeypatch.setenv("API_PORT", "9000") + s = Settings() + assert s.api_port == 9000 + + +# ── Singleton import ───────────────────────────────────────────────────────── + +class TestSingleton: + """Verify the module-level ``settings`` instance is usable.""" + + def test_settings_singleton_importable(self): + from libs.config.settings import settings + assert isinstance(settings, Settings) + + def test_settings_singleton_has_expected_type(self): + from libs.config.settings import settings + assert hasattr(settings, "redis_url") + assert hasattr(settings, "max_events_per_track") + assert hasattr(settings, "track_ttl_seconds") + assert hasattr(settings, "lingering_threshold_sec")