Compare commits
4 Commits
79e883f77d
...
0bc928799a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bc928799a | |||
| d6c48b495a | |||
| 57275d5172 | |||
| fad517e56a |
@@ -37,6 +37,7 @@ uvicorn app.main:app --reload
|
|||||||
- `http://127.0.0.1:8000/health` - Health-Check
|
- `http://127.0.0.1:8000/health` - Health-Check
|
||||||
- `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation
|
- `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/v1/entities` - Home-Assistant-Entities
|
||||||
|
- `http://127.0.0.1:8000/ml/health` - ML-Serving Health (ab ML-005)
|
||||||
|
|
||||||
### ENV-Konfiguration (`.env.example`)
|
### ENV-Konfiguration (`.env.example`)
|
||||||
- `SILLYHOME_HA_URL` – Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`)
|
- `SILLYHOME_HA_URL` – Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`)
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
from app.ml.feature_store import FeatureVector
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Predictor:
|
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._pipeline = pipeline
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
def predict(self, artifact_id: str, entity: FeatureVector) -> str:
|
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:
|
if entity.sensor_id not in artifact.supported_sensors:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Sensor '{entity.sensor_id}' wird vom Modell '{artifact_id}' nicht unterstützt."
|
f"Sensor '{entity.sensor_id}' wird vom Modell '{artifact_id}' nicht unterstützt."
|
||||||
@@ -30,4 +40,11 @@ class Predictor:
|
|||||||
artifacts = list(pipeline._artifacts)
|
artifacts = list(pipeline._artifacts)
|
||||||
if not artifacts:
|
if not artifacts:
|
||||||
raise ValueError("Kein trainiertes Modell gefunden.")
|
raise ValueError("Kein trainiertes Modell gefunden.")
|
||||||
return pipeline.export(artifacts[-1])
|
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.")
|
||||||
3
app/ml/registry/__init__.py
Normal file
3
app/ml/registry/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .model_registry import ModelRegistry
|
||||||
|
|
||||||
|
__all__ = ["ModelRegistry"]
|
||||||
37
app/ml/registry/model_registry.py
Normal file
37
app/ml/registry/model_registry.py
Normal file
@@ -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)
|
||||||
37
backend/app.py
Normal file
37
backend/app.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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, FeatureVector
|
||||||
|
|
||||||
|
|
||||||
|
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 _app_state(): # noqa: ANN001
|
||||||
|
return app.state
|
||||||
|
|
||||||
|
|
||||||
|
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(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)
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
104
backend/routes/ml.py
Normal file
104
backend/routes/ml.py
Normal file
@@ -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")
|
||||||
116
docs/ml_api.md
Normal file
116
docs/ml_api.md
Normal file
@@ -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`
|
||||||
Reference in New Issue
Block a user