From fad517e56a0dd2cc0b65b4a3afbd93edda447519 Mon Sep 17 00:00:00 2001 From: Pino Date: Thu, 11 Jun 2026 12:04:52 +0200 Subject: [PATCH] ML-005 vorbereiten: Registry, API-Routen und kompatibler Predictor --- app/ml/predictor.py | 27 ++++++-- app/ml/registry/__init__.py | 3 + app/ml/registry/model_registry.py | 37 +++++++++++ backend/app.py | 32 +++++++++ backend/routes/ml.py | 104 ++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 app/ml/registry/__init__.py create mode 100644 app/ml/registry/model_registry.py create mode 100644 backend/app.py create mode 100644 backend/routes/ml.py diff --git a/app/ml/predictor.py b/app/ml/predictor.py index ff02d41..578d822 100644 --- a/app/ml/predictor.py +++ b/app/ml/predictor.py @@ -1,21 +1,31 @@ from __future__ import annotations import logging -from functools import lru_cache from typing import Sequence from app.ml.feature_store import FeatureVector -from app.ml.training import TrainingPipeline, TrainedArtifact +from app.ml.registry.model_registry import ModelRegistry +from app.ml.training import TrainedArtifact, TrainingPipeline logger = logging.getLogger(__name__) class Predictor: - def __init__(self, pipeline: TrainingPipeline) -> None: + def __init__( + self, + pipeline: TrainingPipeline | None = None, + registry: ModelRegistry | None = None, + ) -> None: + if isinstance(pipeline, ModelRegistry) and registry is None: + registry = pipeline + pipeline = None + if pipeline is None and registry is None: + raise ValueError("Predictor erfordert TrainingPipeline oder ModelRegistry.") self._pipeline = pipeline + self._registry = registry def predict(self, artifact_id: str, entity: FeatureVector) -> str: - artifact = self._pipeline.export(artifact_id) + artifact = self._get_artifact(artifact_id) if entity.sensor_id not in artifact.supported_sensors: raise ValueError( f"Sensor '{entity.sensor_id}' wird vom Modell '{artifact_id}' nicht unterstützt." @@ -30,4 +40,11 @@ class Predictor: artifacts = list(pipeline._artifacts) if not artifacts: raise ValueError("Kein trainiertes Modell gefunden.") - return pipeline.export(artifacts[-1]) \ No newline at end of file + return pipeline.export(artifacts[-1]) + + def _get_artifact(self, artifact_id: str) -> TrainedArtifact: + if self._registry is not None: + return self._registry.load_artifact(artifact_id) + if self._pipeline is not None: + return self._pipeline.export(artifact_id) + raise RuntimeError("Predictor nicht initialisiert.") \ No newline at end of file diff --git a/app/ml/registry/__init__.py b/app/ml/registry/__init__.py new file mode 100644 index 0000000..10c79a2 --- /dev/null +++ b/app/ml/registry/__init__.py @@ -0,0 +1,3 @@ +from .model_registry import ModelRegistry + +__all__ = ["ModelRegistry"] \ No newline at end of file diff --git a/app/ml/registry/model_registry.py b/app/ml/registry/model_registry.py new file mode 100644 index 0000000..a236d24 --- /dev/null +++ b/app/ml/registry/model_registry.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Iterable + +from app.ml.training import TrainedArtifact + +logger = logging.getLogger(__name__) + + +class ModelRegistry: + def __init__(self, root: str | Path) -> None: + self._root = Path(root) + self._root.mkdir(parents=True, exist_ok=True) + self._artifacts: dict[str, TrainedArtifact] = {} + + def register(self, artifact: TrainedArtifact) -> TrainedArtifact: + self._artifacts[artifact.artifact_id] = artifact + self._persist(artifact) + return artifact + + def load_artifact(self, artifact_id: str) -> TrainedArtifact: + if artifact_id not in self._artifacts: + raise KeyError(f"Artifact '{artifact_id}' nicht registriert.") + return self._artifacts[artifact_id] + + def list_models(self) -> Iterable[TrainedArtifact]: + return list(self._artifacts.values()) + + def _persist(self, artifact: TrainedArtifact) -> None: + target = self._root / f"{artifact.artifact_id}.json" + target.write_text( + f"{artifact.artifact_id}\t{','.join(artifact.supported_sensors)}\n", + encoding="utf-8", + ) + logger.info("Modell gespeichert: %s", target) \ No newline at end of file diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..e94ba19 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,32 @@ +from fastapi import FastAPI + +from backend.routes.ml import init_ml_routes +from app.ml.registry.model_registry import ModelRegistry +from app.ml.training import TrainingPipeline +from app.ml.feature_store import FeatureStore + + +def create_app() -> FastAPI: + application = FastAPI(title="SillyHome Next ML") + init_ml_routes(application) + _seed_default_model(application.state if hasattr(application, "state") else application) + return application + + +def _seed_default_model(state) -> None: # noqa: ANN001 + registry = getattr(state, "registry", None) + if registry is None: + registry = ModelRegistry(".model_store") + state.registry = registry + + if list(registry.list_models()): + return + + store = FeatureStore() + store.add("sensor.kitchen", {"temperature": 19.0}) + pipeline = TrainingPipeline(store) + artifact = pipeline.run("default") + registry.register(artifact) + + +app = create_app() \ No newline at end of file diff --git a/backend/routes/ml.py b/backend/routes/ml.py new file mode 100644 index 0000000..7044724 --- /dev/null +++ b/backend/routes/ml.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import List, Sequence + +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from app.ml.feature_store import FeatureVector +from app.ml.predictor import Predictor +from app.ml.registry.model_registry import ModelRegistry +from app.ml.training import TrainedArtifact + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ml", tags=["ml"]) + + +class HealthResponse(BaseModel): + status: str + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class PredictRequest(BaseModel): + model_id: str = Field(..., alias="modelId") + sensor_id: str + values: dict + + +class PredictResponse(BaseModel): + model_id: str + sensor_id: str + prediction: str + + +class BatchRequest(BaseModel): + requests: Sequence[PredictRequest] + + +class BatchResponse(BaseModel): + predictions: Sequence[PredictResponse] + + +class ModelsResponse(BaseModel): + models: List[str] + + +@router.get("/health", response_model=HealthResponse, status_code=200) +def health() -> HealthResponse: + return HealthResponse(status="ok") + + +@router.get("/models", response_model=ModelsResponse, status_code=200) +def list_models() -> ModelsResponse: + registry = _require_registry() + models = [artifact.artifact_id for artifact in registry.list_models()] + return ModelsResponse(models=models) + + +@router.post("/predict", response_model=PredictResponse, status_code=200) +def predict(request: PredictRequest) -> PredictResponse: + registry = _require_registry() + predictor = Predictor(registry=registry) + vector = FeatureVector(sensor_id=request.sensor_id, values=request.values) + try: + prediction = predictor.predict(request.model_id, vector) + except KeyError as exc: # unknown artifact + raise _not_found_error(str(exc)) from exc + return PredictResponse(model_id=request.model_id, sensor_id=request.sensor_id, prediction=prediction) + + +@router.post("/batch", response_model=BatchResponse, status_code=200) +def predict_batch(request: BatchRequest) -> BatchResponse: + registry = _require_registry() + predictor = Predictor(registry=registry) + responses: List[PredictResponse] = [] + for item in request.requests: + vector = FeatureVector(sensor_id=item.sensor_id, values=item.values) + try: + prediction = predictor.predict(item.model_id, vector) + except KeyError as exc: + raise _not_found_error(str(exc)) from exc + responses.append( + PredictResponse(model_id=item.model_id, sensor_id=item.sensor_id, prediction=prediction) + ) + return BatchResponse(predictions=responses) + + +def _require_registry() -> ModelRegistry: + from backend.app import _app_state + + state_obj = _app_state() + registry = getattr(state_obj, "registry", None) + if registry is None: + raise RuntimeError("ML registry nicht initialisiert.") + return registry + + +def init_ml_routes(app) -> None: # noqa: ANN001 + registry = ModelRegistry(".model_store") + app.state.registry = registry + app.include_router(router) + logger.info("ML routes registered") \ No newline at end of file