From fad517e56a0dd2cc0b65b4a3afbd93edda447519 Mon Sep 17 00:00:00 2001 From: Pino Date: Thu, 11 Jun 2026 12:04:52 +0200 Subject: [PATCH 1/3] 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 From 57275d5172860da8eac011a4db5962a3d1105a9f Mon Sep 17 00:00:00 2001 From: Pino Date: Thu, 11 Jun 2026 13:27:01 +0200 Subject: [PATCH 2/3] =?UTF-8?q?ML-005:=20Doku=20zu=20ML-Serving-API=20erg?= =?UTF-8?q?=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/ml_api.md | 116 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 docs/ml_api.md diff --git a/README.md b/README.md index 7165428..13da62c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ uvicorn app.main:app --reload - `http://127.0.0.1:8000/health` - Health-Check - `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation - `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities +- `http://127.0.0.1:8000/ml/health` - ML-Serving Health (ab ML-005) ### ENV-Konfiguration (`.env.example`) - `SILLYHOME_HA_URL` – Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`) diff --git a/docs/ml_api.md b/docs/ml_api.md new file mode 100644 index 0000000..afced19 --- /dev/null +++ b/docs/ml_api.md @@ -0,0 +1,116 @@ +# ML-Serving-API + +Diese Dokumentation beschreibt die REST-Endpoints für ML-Vorhersagen in SillyHome Next. + +## Basis-URL + +- Standard: `http://127.0.0.1:8000/ml` +- Health: `/health` +- Modelle: `/models` +- Einzelvorhersage: `/predict` +- Batchvorhersage: `/batch` + +Der Standard-Start erfolgt über `uvicorn backend.app:app --reload`, danach steht die API unter `/ml` bereit. + +## Endpoints + +### `GET /ml/health` + +Health-Check der ML-Services. + +**Beispielantwort** +```json +{ + "status": "ok", + "updated_at": "2026-06-11T12:00:00Z" +} +``` + +### `GET /ml/models` + +Listet alle registrierten Modell-Artefakte auf. + +**Beispielantwort** +```json +{ + "models": ["default"] +} +``` + +### `POST /ml/predict` + +Einzelne Vorhersage für einen Sensor. + +**Request** +```json +{ + "modelId": "default", + "sensor_id": "sensor.kitchen", + "values": {"temperature": 21.0} +} +``` + +**Antwort** +```json +{ + "model_id": "default", + "sensor_id": "sensor.kitchen", + "prediction": "default:sensor.kitchen:{'temperature': 21.0}" +} +``` + +### `POST /ml/batch` + +Batch-Vorhersage für mehrere Sensorwerte. + +**Request** +```json +{ + "requests": [ + { + "modelId": "default", + "sensor_id": "sensor.kitchen", + "values": {"temperature": 21.0} + }, + { + "modelId": "default", + "sensor_id": "sensor.bedroom", + "values": {"temperature": 18.5} + } + ] +} +``` + +**Antwort** +```json +{ + "predictions": [ + { + "model_id": "default", + "sensor_id": "sensor.kitchen", + "prediction": "default:sensor.kitchen:{'temperature': 21.0}" + }, + { + "model_id": "default", + "sensor_id": "sensor.bedroom", + "prediction": "default:sensor.bedroom:{'temperature': 18.5}" + } + ] +} +``` + +## Fehlerfälle + +- `400 Bad Request`: Fehlende oder ungültige Felder. +- `404 Not Found`: Modell oder Sensor nicht registriert. +- `500 Internal Server Error`: Registry nicht initialisiert oder unerwarteter Fehler. + +## Betrieb + +Beim Start wird automatisch ein Default-Artefakt erstellt, falls noch kein Modell registriert ist. Neue Modelle müssen zusätzlich über `ModelRegistry.register(...)` eingetragen werden. + +## Verweise + +- `app/ml/predictor.py` +- `app/ml/registry/model_registry.py` +- `backend/routes/ml.py` \ No newline at end of file From d6c48b495aeb36c31878fbf7e031f08b4c672d7d Mon Sep 17 00:00:00 2001 From: Pino Date: Thu, 11 Jun 2026 13:28:40 +0200 Subject: [PATCH 3/3] ML-005: FastAPI-App-Start und Batch-Sensor-Support finalisieren --- backend/app.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/app.py b/backend/app.py index e94ba19..af0d9a3 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3,7 +3,7 @@ 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 +from app.ml.feature_store import FeatureStore, FeatureVector def create_app() -> FastAPI: @@ -13,6 +13,10 @@ def create_app() -> FastAPI: return application +def _app_state(): # noqa: ANN001 + return app.state + + def _seed_default_model(state) -> None: # noqa: ANN001 registry = getattr(state, "registry", None) if registry is None: @@ -23,7 +27,8 @@ def _seed_default_model(state) -> None: # noqa: ANN001 return store = FeatureStore() - store.add("sensor.kitchen", {"temperature": 19.0}) + store.add(FeatureVector(sensor_id="sensor.front_door", values={"contact": 1.0})) + store.add(FeatureVector(sensor_id="sensor.living_room", values={"temperature": 21.0})) pipeline = TrainingPipeline(store) artifact = pipeline.run("default") registry.register(artifact)