Compare commits
5 Commits
0bc928799a
...
otto/produ
| Author | SHA1 | Date | |
|---|---|---|---|
| aaf319ff14 | |||
| 471146761e | |||
| 3bed5e790a | |||
| 4b3dc3b7af | |||
| 63d10a6c4f |
@@ -12,6 +12,5 @@ node_modules
|
|||||||
.vscode
|
.vscode
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
README.md
|
|
||||||
.dockerignore
|
.dockerignore
|
||||||
docker-compose*.yml
|
docker-compose*.yml
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
SILLYHOME_HA_URL=http://homeassistant.local:8123
|
SILLYHOME_HA_URL=http://homeassistant.local:8123
|
||||||
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN
|
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN
|
||||||
|
SILLYHOME_MODEL_STORE=.model_store
|
||||||
|
|||||||
24
.gitea/workflows/quality.yml
Normal file
24
.gitea/workflows/quality.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "otto/**", "feature/**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.11", "3.13"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
- run: python -m pip install --upgrade pip
|
||||||
|
- run: python -m pip install -e ".[dev]"
|
||||||
|
- run: python -m pytest
|
||||||
|
- run: ruff check .
|
||||||
|
- run: mypy
|
||||||
@@ -3,3 +3,8 @@
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
- Projektinitiierung
|
- Projektinitiierung
|
||||||
- Architektur, ADRs und Roadmap
|
- Architektur, ADRs und Roadmap
|
||||||
|
- Einheitliche produktive FastAPI-App für HA- und ML-Routen
|
||||||
|
- Funktionierende ENV-Konfiguration und sauberer HA-503-Zustand
|
||||||
|
- Persistente, validierte und gegen Path Traversal gehärtete Model Registry
|
||||||
|
- Reproduzierbares Packaging, CI-Gates und gehärteter non-root Container
|
||||||
|
- Definierte API-Fehler und korrigierte Evaluationsmetriken
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -1,15 +1,27 @@
|
|||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
SILLYHOME_MODEL_STORE=/app/data/models
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml ./
|
RUN addgroup --system sillyhome && adduser --system --ingroup sillyhome sillyhome
|
||||||
RUN python -m pip install --upgrade pip && \
|
|
||||||
pip install --no-cache-dir -e ".[dev]"
|
|
||||||
|
|
||||||
COPY . .
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY app ./app
|
||||||
|
COPY backend ./backend
|
||||||
|
RUN python -m pip install --upgrade pip && \
|
||||||
|
python -m pip install . && \
|
||||||
|
mkdir -p /app/data/models && \
|
||||||
|
chown -R sillyhome:sillyhome /app/data
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
USER sillyhome
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -1,6 +1,13 @@
|
|||||||
# SillyHome Next
|
# SillyHome Next
|
||||||
|
|
||||||
Modern, lokal-first und datenschutzfreundliches Smart-Home-Intelligenzsystem für Home Assistant.
|
Lokaler, datenschutzfreundlicher API-Prototyp für Home Assistant.
|
||||||
|
|
||||||
|
## Reifegrad
|
||||||
|
|
||||||
|
Version `0.1.0` stellt eine gehärtete technische Basis bereit: Home-Assistant-Entities
|
||||||
|
lesen, regelbasierte Bausteine und eine persistente Modell-Artefakt-Registry. Die
|
||||||
|
aktuelle Trainings- und Vorhersagelogik ist noch eine deterministische
|
||||||
|
Schnittstellen-Implementierung und **kein produktives Machine-Learning-Modell**.
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit.
|
TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit.
|
||||||
@@ -13,14 +20,12 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
|
|||||||
- Lokal-first ohne Cloudpflicht
|
- Lokal-first ohne Cloudpflicht
|
||||||
- Erweiterbar, testbar, dokumentiert
|
- Erweiterbar, testbar, dokumentiert
|
||||||
|
|
||||||
## APPENDIX
|
## Quickstart
|
||||||
|
|
||||||
### Quickstart
|
|
||||||
1. Python-Venv anlegen und Abhängigkeiten installieren:
|
1. Python-Venv anlegen und Abhängigkeiten installieren:
|
||||||
```bash
|
```bash
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -e .
|
pip install -e ".[dev]"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Konfiguration aus `.env.example` übernehmen und anpassen:
|
2. Konfiguration aus `.env.example` übernehmen und anpassen:
|
||||||
@@ -37,17 +42,32 @@ 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)
|
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health
|
||||||
|
|
||||||
|
Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up --build -d
|
||||||
|
curl --fail http://127.0.0.1:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose veröffentlicht die API standardmäßig nur auf `127.0.0.1`. Für Zugriff aus
|
||||||
|
dem Netz muss ein authentifizierender Reverse Proxy vorgeschaltet werden.
|
||||||
|
|
||||||
### 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`)
|
||||||
- `SILLYHOME_HA_TOKEN` – Long-Lived Access Token aus Home Assistant (nur lesen)
|
- `SILLYHOME_HA_TOKEN` – Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten
|
||||||
|
- `SILLYHOME_MODEL_STORE` – Verzeichnis für persistierte Modell-Metadaten
|
||||||
|
|
||||||
Hinweis: Nutze ausschließlich Long-Lived Access Tokens mit Leserechten. Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht in Versionskontrollsysteme.
|
Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins
|
||||||
|
Versionskontrollsystem.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
```bash
|
```bash
|
||||||
pytest -q
|
pytest
|
||||||
ruff check .
|
ruff check .
|
||||||
mypy app tests
|
mypy
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,38 +2,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.dependencies import get_ha_reader
|
||||||
from app.ha.models import HaEntitySummary
|
from app.ha.models import HaEntitySummary
|
||||||
from app.ha.reader import HaReader
|
from app.ha.reader import HaReader
|
||||||
from app.rules.recommender import Recommender
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1", tags=["entities"])
|
router = APIRouter(prefix="/v1", tags=["entities"])
|
||||||
|
|
||||||
|
|
||||||
def _state_ha_reader(request: Request) -> HaReader:
|
|
||||||
try:
|
|
||||||
return request.app.state.ha_reader
|
|
||||||
except AttributeError as exc:
|
|
||||||
raise HTTPException(status_code=503, detail="HA-Reader nicht initialisiert.") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _state_recommender(request: Request) -> Recommender:
|
|
||||||
try:
|
|
||||||
return request.app.state.recommender
|
|
||||||
except AttributeError as exc:
|
|
||||||
raise HTTPException(status_code=503, detail="Recommender nicht initialisiert.") from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/entities",
|
"/entities",
|
||||||
summary="Home-Assistant-Entities auflisten",
|
summary="Home-Assistant-Entities auflisten",
|
||||||
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.",
|
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.",
|
||||||
response_model=List[HaEntitySummary],
|
response_model=List[HaEntitySummary],
|
||||||
)
|
)
|
||||||
def list_entities(request: Request) -> List[HaEntitySummary]:
|
def list_entities(ha_reader: HaReader = Depends(get_ha_reader)) -> List[HaEntitySummary]:
|
||||||
ha_reader = _state_ha_reader(request)
|
return list(ha_reader.read_entities())
|
||||||
recommender = _state_recommender(request)
|
|
||||||
entities = ha_reader.read_entities()
|
|
||||||
recommender.run(entities)
|
|
||||||
return entities
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from dataclasses import dataclass
|
|||||||
class Settings:
|
class Settings:
|
||||||
ha_url: str | None = None
|
ha_url: str | None = None
|
||||||
ha_token: str | None = None
|
ha_token: str | None = None
|
||||||
|
model_store: str = ".model_store"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ha_configured(self) -> bool:
|
def ha_configured(self) -> bool:
|
||||||
@@ -18,4 +19,5 @@ def load_settings() -> Settings:
|
|||||||
return Settings(
|
return Settings(
|
||||||
ha_url=os.getenv("SILLYHOME_HA_URL") or os.getenv("HA_URL"),
|
ha_url=os.getenv("SILLYHOME_HA_URL") or os.getenv("HA_URL"),
|
||||||
ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"),
|
ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"),
|
||||||
|
model_store=os.getenv("SILLYHOME_MODEL_STORE", ".model_store"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,10 +31,13 @@ class HaClient:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._session.close()
|
||||||
|
|
||||||
def list_entities(self) -> list[dict[str, object]]:
|
def list_entities(self) -> list[dict[str, object]]:
|
||||||
try:
|
try:
|
||||||
response = self._session.get(
|
response = self._session.get(
|
||||||
f"{self._settings.url}/api/states",
|
f"{self._settings.url.rstrip('/')}/api/states",
|
||||||
timeout=self._settings.timeout_seconds,
|
timeout=self._settings.timeout_seconds,
|
||||||
)
|
)
|
||||||
except requests.Timeout as exc:
|
except requests.Timeout as exc:
|
||||||
@@ -71,4 +74,4 @@ class HaClient:
|
|||||||
"Antwort von Home Assistant hat unerwartetes Format."
|
"Antwort von Home Assistant hat unerwartetes Format."
|
||||||
)
|
)
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ class HaReader:
|
|||||||
entities = self._client.list_entities()
|
entities = self._client.list_entities()
|
||||||
summaries: list[HaEntitySummary] = []
|
summaries: list[HaEntitySummary] = []
|
||||||
for item in entities:
|
for item in entities:
|
||||||
entity_id = item.get("entity_id", "")
|
raw_entity_id = item.get("entity_id")
|
||||||
if "." not in entity_id:
|
if not isinstance(raw_entity_id, str) or "." not in raw_entity_id:
|
||||||
continue
|
continue
|
||||||
|
entity_id = raw_entity_id
|
||||||
domain = entity_id.split(".", 1)[0]
|
domain = entity_id.split(".", 1)[0]
|
||||||
raw_attributes = item.get("attributes") or {}
|
raw_attributes = item.get("attributes") or {}
|
||||||
attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}
|
attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}
|
||||||
|
|||||||
45
app/main.py
45
app/main.py
@@ -1,34 +1,38 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from app.api.v1.entities import router as entities_router
|
from app.api.v1.entities import router as entities_router
|
||||||
|
from app.config import load_settings
|
||||||
from app.core.exception_handlers import register_exception_handlers
|
from app.core.exception_handlers import register_exception_handlers
|
||||||
from app.ha.client import HaClient, HaClientSettings
|
from app.ha.client import HaClient, HaClientSettings
|
||||||
from app.ha.reader import HaReader
|
from app.ha.reader import HaReader
|
||||||
from app.rules.recommender import Recommender
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
from app.rules.heating import HeatingRule
|
from backend.routes.ml import init_ml_routes
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
settings = app.state.settings
|
settings = app.state.settings
|
||||||
ha_url = getattr(settings, "ha_url", None)
|
client: HaClient | None = None
|
||||||
ha_token = getattr(settings, "ha_token", None)
|
app.state.registry = ModelRegistry(settings.model_store)
|
||||||
client = HaClient(
|
if hasattr(app.state, "ha_reader"):
|
||||||
settings=HaClientSettings(
|
del app.state.ha_reader
|
||||||
url=ha_url or "",
|
if settings.ha_configured:
|
||||||
token=ha_token or "",
|
client = HaClient(
|
||||||
|
settings=HaClientSettings(
|
||||||
|
url=cast(str, settings.ha_url),
|
||||||
|
token=cast(str, settings.ha_token),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
app.state.ha_reader = HaReader(client=client)
|
||||||
app.state.ha_reader = HaReader(client=client)
|
try:
|
||||||
app.state.recommender = Recommender(rules=[HeatingRule()])
|
yield
|
||||||
yield
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
client.close()
|
||||||
class Settings:
|
|
||||||
ha_url: str = "http://localhost:8123"
|
|
||||||
ha_token: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -37,9 +41,10 @@ app = FastAPI(
|
|||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = Settings()
|
app.state.settings = load_settings()
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
app.include_router(entities_router)
|
app.include_router(entities_router)
|
||||||
|
init_ml_routes(app, model_store=app.state.settings.model_store)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -49,4 +54,4 @@ def health() -> dict[str, str]:
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root() -> dict[str, str]:
|
def root() -> dict[str, str]:
|
||||||
return {"service": "sillyhome-next", "docs": "/docs"}
|
return {"service": "sillyhome-next", "docs": "/docs"}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
"""Machine-Learning-Grundbausteine für SillyHome Next."""
|
"""Machine-Learning-Grundbausteine für SillyHome Next."""
|
||||||
__all__ = ["FeatureStore", "FeatureVector"]
|
__all__ = ["FeatureStore", "FeatureVector", "TrainedArtifact", "TrainingPipeline"]
|
||||||
from app.ml.feature_store import FeatureStore, FeatureVector
|
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||||
from app.ml.training import TrainedArtifact, TrainingPipeline
|
from app.ml.training import TrainedArtifact, TrainingPipeline
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from collections.abc import Sequence
|
||||||
from typing import Sequence
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from app.ml.feature_store import FeatureVector
|
from app.ml.training import TrainingPipeline
|
||||||
from app.ml.training import TrainingPipeline, TrainedArtifact
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,15 +28,16 @@ class Evaluator:
|
|||||||
self._pipeline = pipeline
|
self._pipeline = pipeline
|
||||||
|
|
||||||
def evaluate(self, artifact_id: str, predictions: Sequence[str]) -> EvalReport:
|
def evaluate(self, artifact_id: str, predictions: Sequence[str]) -> EvalReport:
|
||||||
artifacts = list(self._pipeline._artifacts)
|
try:
|
||||||
if not artifacts:
|
supported_sensors = set(self._pipeline.export(artifact_id).supported_sensors)
|
||||||
raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.")
|
except KeyError as exc:
|
||||||
|
raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.") from exc
|
||||||
|
|
||||||
supported_sensors = self._pipeline.export(artifact_id).supported_sensors
|
parsed_sensors = [_prediction_sensor(prediction) for prediction in predictions]
|
||||||
unknown_hits = sum(1 for prediction in predictions if ":" not in prediction)
|
supported_hits = sum(sensor in supported_sensors for sensor in parsed_sensors)
|
||||||
supported_references = sum(1 for sensor in supported_sensors for prediction in predictions if sensor in prediction)
|
unknown_hits = sum(sensor not in supported_sensors for sensor in parsed_sensors)
|
||||||
sample_size = len(predictions)
|
sample_size = len(predictions)
|
||||||
coverage = supported_references / sample_size if sample_size else 0.0
|
coverage = supported_hits / sample_size if sample_size else 0.0
|
||||||
unknown_rate = unknown_hits / sample_size if sample_size else 0.0
|
unknown_rate = unknown_hits / sample_size if sample_size else 0.0
|
||||||
|
|
||||||
coverage_metric = Metric(name="coverage", value=coverage, threshold=0.8)
|
coverage_metric = Metric(name="coverage", value=coverage, threshold=0.8)
|
||||||
@@ -54,4 +54,11 @@ class Evaluator:
|
|||||||
coverage,
|
coverage,
|
||||||
unknown_rate,
|
unknown_rate,
|
||||||
)
|
)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_sensor(prediction: str) -> str | None:
|
||||||
|
parts = prediction.split(":", 2)
|
||||||
|
if len(parts) != 3 or not parts[0] or not parts[1]:
|
||||||
|
return None
|
||||||
|
return parts[1]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass, field
|
from collections.abc import Iterable
|
||||||
from typing import Iterable
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -28,4 +28,4 @@ class FeatureStore:
|
|||||||
return series[-1] if series else None
|
return series[-1] if series else None
|
||||||
|
|
||||||
def all(self) -> list[FeatureVector]:
|
def all(self) -> list[FeatureVector]:
|
||||||
return [vector for vectors in self._vectors.values() for vector in vectors]
|
return [vector for vectors in self._vectors.values() for vector in vectors]
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
import re
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from app.ml.training import TrainedArtifact
|
from app.ml.training import TrainedArtifact
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ARTIFACT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
|
||||||
|
|
||||||
class ModelRegistry:
|
class ModelRegistry:
|
||||||
def __init__(self, root: str | Path) -> None:
|
def __init__(self, root: str | Path) -> None:
|
||||||
self._root = Path(root)
|
self._root = Path(root).resolve()
|
||||||
self._root.mkdir(parents=True, exist_ok=True)
|
self._root.mkdir(parents=True, exist_ok=True)
|
||||||
self._artifacts: dict[str, TrainedArtifact] = {}
|
self._artifacts: dict[str, TrainedArtifact] = {}
|
||||||
|
self._load_existing()
|
||||||
|
|
||||||
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
|
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
|
||||||
self._artifacts[artifact.artifact_id] = artifact
|
self._validate_artifact_id(artifact.artifact_id)
|
||||||
self._persist(artifact)
|
self._persist(artifact)
|
||||||
|
self._artifacts[artifact.artifact_id] = artifact
|
||||||
return artifact
|
return artifact
|
||||||
|
|
||||||
def load_artifact(self, artifact_id: str) -> TrainedArtifact:
|
def load_artifact(self, artifact_id: str) -> TrainedArtifact:
|
||||||
|
self._validate_artifact_id(artifact_id)
|
||||||
if artifact_id not in self._artifacts:
|
if artifact_id not in self._artifacts:
|
||||||
raise KeyError(f"Artifact '{artifact_id}' nicht registriert.")
|
raise KeyError(f"Artifact '{artifact_id}' nicht registriert.")
|
||||||
return self._artifacts[artifact_id]
|
return self._artifacts[artifact_id]
|
||||||
@@ -28,10 +36,45 @@ class ModelRegistry:
|
|||||||
def list_models(self) -> Iterable[TrainedArtifact]:
|
def list_models(self) -> Iterable[TrainedArtifact]:
|
||||||
return list(self._artifacts.values())
|
return list(self._artifacts.values())
|
||||||
|
|
||||||
|
def _load_existing(self) -> None:
|
||||||
|
for source in sorted(self._root.glob("*.json")):
|
||||||
|
try:
|
||||||
|
raw = json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
artifact_id = raw["artifact_id"]
|
||||||
|
supported_sensors = raw["supported_sensors"]
|
||||||
|
if not isinstance(artifact_id, str) or not isinstance(supported_sensors, list):
|
||||||
|
raise ValueError("invalid artifact structure")
|
||||||
|
self._validate_artifact_id(artifact_id)
|
||||||
|
if source.name != f"{artifact_id}.json":
|
||||||
|
raise ValueError("artifact id does not match filename")
|
||||||
|
if not all(isinstance(sensor, str) for sensor in supported_sensors):
|
||||||
|
raise ValueError("supported_sensors must contain strings")
|
||||||
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise ValueError(f"Ungültiges Modell-Artefakt: {source.name}") from exc
|
||||||
|
|
||||||
|
self._artifacts[artifact_id] = TrainedArtifact(
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
supported_sensors=tuple(supported_sensors),
|
||||||
|
)
|
||||||
|
|
||||||
def _persist(self, artifact: TrainedArtifact) -> None:
|
def _persist(self, artifact: TrainedArtifact) -> None:
|
||||||
target = self._root / f"{artifact.artifact_id}.json"
|
target = self._root / f"{artifact.artifact_id}.json"
|
||||||
target.write_text(
|
temporary = target.with_suffix(".json.tmp")
|
||||||
f"{artifact.artifact_id}\t{','.join(artifact.supported_sensors)}\n",
|
payload = {
|
||||||
|
"artifact_id": artifact.artifact_id,
|
||||||
|
"supported_sensors": list(artifact.supported_sensors),
|
||||||
|
}
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
logger.info("Modell gespeichert: %s", target)
|
os.replace(temporary, target)
|
||||||
|
logger.info("Modell gespeichert: %s", target)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_artifact_id(artifact_id: str) -> None:
|
||||||
|
if not _ARTIFACT_ID_PATTERN.fullmatch(artifact_id) or ".." in artifact_id:
|
||||||
|
raise ValueError(
|
||||||
|
"artifact_id darf nur Buchstaben, Ziffern, Punkt, Unterstrich "
|
||||||
|
"und Bindestrich enthalten."
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from app.ml.feature_store import FeatureVector, FeatureStore
|
from app.ml.feature_store import FeatureStore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ class TrainingPipeline:
|
|||||||
if not vectors:
|
if not vectors:
|
||||||
raise ValueError("FeatureStore enthält keine Trainingsdaten.")
|
raise ValueError("FeatureStore enthält keine Trainingsdaten.")
|
||||||
|
|
||||||
sensors = tuple({vector.sensor_id for vector in vectors})
|
sensors = tuple(sorted({vector.sensor_id for vector in vectors}))
|
||||||
artifact = TrainedArtifact(artifact_id=artifact_id, supported_sensors=sensors)
|
artifact = TrainedArtifact(artifact_id=artifact_id, supported_sensors=sensors)
|
||||||
self._artifacts[artifact_id] = artifact
|
self._artifacts[artifact_id] = artifact
|
||||||
logger.info("Training abgeschlossen für %s mit %d Sensoren", artifact_id, len(sensors))
|
logger.info("Training abgeschlossen für %s mit %d Sensoren", artifact_id, len(sensors))
|
||||||
@@ -34,4 +33,4 @@ class TrainingPipeline:
|
|||||||
def export(self, artifact_id: str) -> TrainedArtifact:
|
def export(self, artifact_id: str) -> TrainedArtifact:
|
||||||
if artifact_id not in self._artifacts:
|
if artifact_id not in self._artifacts:
|
||||||
raise KeyError(f"Artifact '{artifact_id}' nicht gefunden.")
|
raise KeyError(f"Artifact '{artifact_id}' nicht gefunden.")
|
||||||
return self._artifacts[artifact_id]
|
return self._artifacts[artifact_id]
|
||||||
|
|||||||
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Secondary application entry points for SillyHome Next."""
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from starlette.datastructures import State
|
||||||
|
|
||||||
from backend.routes.ml import init_ml_routes
|
from backend.routes.ml import init_ml_routes
|
||||||
from app.ml.registry.model_registry import ModelRegistry
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
@@ -6,18 +10,20 @@ from app.ml.training import TrainingPipeline
|
|||||||
from app.ml.feature_store import FeatureStore, FeatureVector
|
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
|
||||||
|
application.state.registry = ModelRegistry(application.state.model_store)
|
||||||
|
_seed_default_model(application.state)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
application = FastAPI(title="SillyHome Next ML")
|
application = FastAPI(title="SillyHome Next ML", lifespan=lifespan)
|
||||||
init_ml_routes(application)
|
init_ml_routes(application)
|
||||||
_seed_default_model(application.state if hasattr(application, "state") else application)
|
|
||||||
return application
|
return application
|
||||||
|
|
||||||
|
|
||||||
def _app_state(): # noqa: ANN001
|
def _seed_default_model(state: State) -> None:
|
||||||
return app.state
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_default_model(state) -> None: # noqa: ANN001
|
|
||||||
registry = getattr(state, "registry", None)
|
registry = getattr(state, "registry", None)
|
||||||
if registry is None:
|
if registry is None:
|
||||||
registry = ModelRegistry(".model_store")
|
registry = ModelRegistry(".model_store")
|
||||||
@@ -34,4 +40,4 @@ def _seed_default_model(state) -> None: # noqa: ANN001
|
|||||||
registry.register(artifact)
|
registry.register(artifact)
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
1
backend/routes/__init__.py
Normal file
1
backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API route modules."""
|
||||||
@@ -2,15 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.ml.feature_store import FeatureVector
|
from app.ml.feature_store import FeatureVector
|
||||||
from app.ml.predictor import Predictor
|
from app.ml.predictor import Predictor
|
||||||
from app.ml.registry.model_registry import ModelRegistry
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
from app.ml.training import TrainedArtifact
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ class HealthResponse(BaseModel):
|
|||||||
class PredictRequest(BaseModel):
|
class PredictRequest(BaseModel):
|
||||||
model_id: str = Field(..., alias="modelId")
|
model_id: str = Field(..., alias="modelId")
|
||||||
sensor_id: str
|
sensor_id: str
|
||||||
values: dict
|
values: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
class PredictResponse(BaseModel):
|
class PredictResponse(BaseModel):
|
||||||
@@ -43,7 +42,7 @@ class BatchResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ModelsResponse(BaseModel):
|
class ModelsResponse(BaseModel):
|
||||||
models: List[str]
|
models: list[str]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthResponse, status_code=200)
|
@router.get("/health", response_model=HealthResponse, status_code=200)
|
||||||
@@ -52,53 +51,66 @@ def health() -> HealthResponse:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/models", response_model=ModelsResponse, status_code=200)
|
@router.get("/models", response_model=ModelsResponse, status_code=200)
|
||||||
def list_models() -> ModelsResponse:
|
def list_models(request: Request) -> ModelsResponse:
|
||||||
registry = _require_registry()
|
registry = _require_registry(request)
|
||||||
models = [artifact.artifact_id for artifact in registry.list_models()]
|
models = [artifact.artifact_id for artifact in registry.list_models()]
|
||||||
return ModelsResponse(models=models)
|
return ModelsResponse(models=models)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/predict", response_model=PredictResponse, status_code=200)
|
@router.post("/predict", response_model=PredictResponse, status_code=200)
|
||||||
def predict(request: PredictRequest) -> PredictResponse:
|
def predict(payload: PredictRequest, request: Request) -> PredictResponse:
|
||||||
registry = _require_registry()
|
registry = _require_registry(request)
|
||||||
predictor = Predictor(registry=registry)
|
predictor = Predictor(registry=registry)
|
||||||
vector = FeatureVector(sensor_id=request.sensor_id, values=request.values)
|
vector = FeatureVector(sensor_id=payload.sensor_id, values=payload.values)
|
||||||
try:
|
try:
|
||||||
prediction = predictor.predict(request.model_id, vector)
|
prediction = predictor.predict(payload.model_id, vector)
|
||||||
except KeyError as exc: # unknown artifact
|
except KeyError as exc:
|
||||||
raise _not_found_error(str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
return PredictResponse(model_id=request.model_id, sensor_id=request.sensor_id, prediction=prediction)
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return PredictResponse(
|
||||||
|
model_id=payload.model_id,
|
||||||
|
sensor_id=payload.sensor_id,
|
||||||
|
prediction=prediction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch", response_model=BatchResponse, status_code=200)
|
@router.post("/batch", response_model=BatchResponse, status_code=200)
|
||||||
def predict_batch(request: BatchRequest) -> BatchResponse:
|
def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
|
||||||
registry = _require_registry()
|
registry = _require_registry(request)
|
||||||
predictor = Predictor(registry=registry)
|
predictor = Predictor(registry=registry)
|
||||||
responses: List[PredictResponse] = []
|
responses: list[PredictResponse] = []
|
||||||
for item in request.requests:
|
for item in payload.requests:
|
||||||
vector = FeatureVector(sensor_id=item.sensor_id, values=item.values)
|
vector = FeatureVector(sensor_id=item.sensor_id, values=item.values)
|
||||||
try:
|
try:
|
||||||
prediction = predictor.predict(item.model_id, vector)
|
prediction = predictor.predict(item.model_id, vector)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise _not_found_error(str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
responses.append(
|
responses.append(
|
||||||
PredictResponse(model_id=item.model_id, sensor_id=item.sensor_id, prediction=prediction)
|
PredictResponse(model_id=item.model_id, sensor_id=item.sensor_id, prediction=prediction)
|
||||||
)
|
)
|
||||||
return BatchResponse(predictions=responses)
|
return BatchResponse(predictions=responses)
|
||||||
|
|
||||||
|
|
||||||
def _require_registry() -> ModelRegistry:
|
def _require_registry(request: Request) -> ModelRegistry:
|
||||||
from backend.app import _app_state
|
registry = getattr(request.app.state, "registry", None)
|
||||||
|
if not isinstance(registry, ModelRegistry):
|
||||||
state_obj = _app_state()
|
raise HTTPException(
|
||||||
registry = getattr(state_obj, "registry", None)
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
if registry is None:
|
detail="ML registry nicht initialisiert.",
|
||||||
raise RuntimeError("ML registry nicht initialisiert.")
|
)
|
||||||
return registry
|
return registry
|
||||||
|
|
||||||
|
|
||||||
def init_ml_routes(app) -> None: # noqa: ANN001
|
def init_ml_routes(app: FastAPI, model_store: str = ".model_store") -> None:
|
||||||
registry = ModelRegistry(".model_store")
|
app.state.model_store = model_store
|
||||||
app.state.registry = registry
|
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
logger.info("ML routes registered")
|
logger.info("ML routes registered")
|
||||||
|
|||||||
@@ -2,7 +2,22 @@ services:
|
|||||||
api:
|
api:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- path: .env
|
||||||
restart: unless-stopped
|
required: false
|
||||||
|
environment:
|
||||||
|
SILLYHOME_MODEL_STORE: /app/data/models
|
||||||
|
volumes:
|
||||||
|
- model-data:/app/data/models
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
model-data:
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# ML-Serving-API
|
# ML-Serving-API
|
||||||
|
|
||||||
Diese Dokumentation beschreibt die REST-Endpoints für ML-Vorhersagen in SillyHome Next.
|
Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen
|
||||||
|
Modell-Artefakt- und Vorhersage-Schnittstelle.
|
||||||
|
|
||||||
|
> Hinweis: Version 0.1.0 enthält noch kein statistisch trainiertes ML-Modell.
|
||||||
|
> Die Vorhersage ist eine deterministische Referenzimplementierung für den
|
||||||
|
> späteren Modellvertrag.
|
||||||
|
|
||||||
## Basis-URL
|
## Basis-URL
|
||||||
|
|
||||||
@@ -10,7 +15,8 @@ Diese Dokumentation beschreibt die REST-Endpoints für ML-Vorhersagen in SillyHo
|
|||||||
- Einzelvorhersage: `/predict`
|
- Einzelvorhersage: `/predict`
|
||||||
- Batchvorhersage: `/batch`
|
- Batchvorhersage: `/batch`
|
||||||
|
|
||||||
Der Standard-Start erfolgt über `uvicorn backend.app:app --reload`, danach steht die API unter `/ml` bereit.
|
Der Standard-Start erfolgt über `uvicorn app.main:app`, danach stehen HA- und
|
||||||
|
ML-Routen in derselben Anwendung bereit.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
@@ -101,16 +107,18 @@ Batch-Vorhersage für mehrere Sensorwerte.
|
|||||||
|
|
||||||
## Fehlerfälle
|
## Fehlerfälle
|
||||||
|
|
||||||
- `400 Bad Request`: Fehlende oder ungültige Felder.
|
- `404 Not Found`: Modell nicht registriert.
|
||||||
- `404 Not Found`: Modell oder Sensor nicht registriert.
|
- `422 Unprocessable Content`: Sensor wird vom Modell nicht unterstützt oder Eingabe ist ungültig.
|
||||||
- `500 Internal Server Error`: Registry nicht initialisiert oder unerwarteter Fehler.
|
- `503 Service Unavailable`: Registry ist nicht initialisiert.
|
||||||
|
|
||||||
## Betrieb
|
## 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.
|
Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Neue Artefakte
|
||||||
|
werden derzeit intern über `ModelRegistry.register(...)` registriert. Die
|
||||||
|
Registry speichert validiertes JSON atomisch und lädt es beim Neustart.
|
||||||
|
|
||||||
## Verweise
|
## Verweise
|
||||||
|
|
||||||
- `app/ml/predictor.py`
|
- `app/ml/predictor.py`
|
||||||
- `app/ml/registry/model_registry.py`
|
- `app/ml/registry/model_registry.py`
|
||||||
- `backend/routes/ml.py`
|
- `backend/routes/ml.py`
|
||||||
|
|||||||
44
docs/ml_training.md
Normal file
44
docs/ml_training.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# ML Training- und Evaluations-Workflow
|
||||||
|
|
||||||
|
Dieser Workflow beschreibt den aktuellen Platzhalter für Modell-Metadaten,
|
||||||
|
Evaluation und Serving. Er trainiert in Version 0.1.0 noch kein statistisches
|
||||||
|
Modell.
|
||||||
|
|
||||||
|
## 1. Daten sammeln
|
||||||
|
|
||||||
|
Alle Trainingsvektoren werden über `FeatureStore.add(...)` oder `add_batch(...)` eingepflegt. Jeder Vektor enthält eine Sensor-ID sowie ein Dictionary mit Merkmalen.
|
||||||
|
|
||||||
|
## 2. Artefakt-Metadaten erzeugen
|
||||||
|
|
||||||
|
```python
|
||||||
|
store = FeatureStore()
|
||||||
|
store.add(FeatureVector(sensor_id="sensor.kitchen", values={"temperature": 21.0}))
|
||||||
|
pipeline = TrainingPipeline(store)
|
||||||
|
artifact = pipeline.run("my_artifact")
|
||||||
|
pipeline.export("my_artifact")
|
||||||
|
```
|
||||||
|
|
||||||
|
`TrainingPipeline.run(...)` erzeugt ein `TrainedArtifact` mit den unterstützten
|
||||||
|
Sensor-IDs. Gewichte, Parameter oder ein echtes Modell werden noch nicht
|
||||||
|
berechnet.
|
||||||
|
|
||||||
|
## 3. Modell evaluieren
|
||||||
|
|
||||||
|
```python
|
||||||
|
evaluator = Evaluator(pipeline)
|
||||||
|
report = evaluator.evaluate(artifact.artifact_id, predictions)
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Report enthält:
|
||||||
|
- `artifact_id`
|
||||||
|
- `sample_size`
|
||||||
|
- Metriken wie `coverage` und `unknown_rate` mit Default-Schwellenwerten
|
||||||
|
|
||||||
|
## 4. Modell registrieren
|
||||||
|
|
||||||
|
Das trainierte Artefakt kann anschließend über `ModelRegistry.register(artifact)` bereitgestellt werden. Die ML-Serving-API stellt es unter `/ml/predict` und `/ml/batch` zur Verfügung.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
- Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet.
|
||||||
|
- Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus.
|
||||||
|
- `coverage` zählt nur exakte Sensor-Referenzen und bleibt im Bereich 0 bis 1.
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -24,6 +28,10 @@ addopts = "-q"
|
|||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
strict = true
|
strict = true
|
||||||
|
files = ["app", "backend", "tests"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app*", "backend*"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ def test_entities_returns_reader_data() -> None:
|
|||||||
|
|
||||||
def test_entities_returns_503_without_home_assistant_config() -> None:
|
def test_entities_returns_503_without_home_assistant_config() -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
if hasattr(app.state, "ha_reader"):
|
|
||||||
delattr(app.state, "ha_reader")
|
|
||||||
response = client.get("/v1/entities")
|
response = client.get("/v1/entities")
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|||||||
52
tests/api/test_ml_routes.py
Normal file
52
tests/api/test_ml_routes.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_routes_are_exposed_by_production_app() -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
health = client.get("/ml/health")
|
||||||
|
models = client.get("/ml/models")
|
||||||
|
|
||||||
|
assert health.status_code == 200
|
||||||
|
assert models.status_code == 200
|
||||||
|
assert isinstance(models.json()["models"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_model_returns_404() -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/ml/predict",
|
||||||
|
json={
|
||||||
|
"modelId": "missing",
|
||||||
|
"sensor_id": "sensor.kitchen",
|
||||||
|
"values": {"temperature": 21.0},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_sensor_returns_422(tmp_path: Path) -> None:
|
||||||
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
|
from app.ml.training import TrainedArtifact
|
||||||
|
|
||||||
|
registry = ModelRegistry(tmp_path)
|
||||||
|
registry.register(TrainedArtifact("default", ("sensor.kitchen",)))
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
app.state.registry = registry
|
||||||
|
response = client.post(
|
||||||
|
"/ml/predict",
|
||||||
|
json={
|
||||||
|
"modelId": "default",
|
||||||
|
"sensor_id": "sensor.unknown",
|
||||||
|
"values": {"temperature": 21.0},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
@@ -21,13 +21,35 @@ def evaluator_factory() -> Evaluator:
|
|||||||
|
|
||||||
def test_evaluate_returns_report_with_metrics() -> None:
|
def test_evaluate_returns_report_with_metrics() -> None:
|
||||||
evaluator = evaluator_factory()
|
evaluator = evaluator_factory()
|
||||||
report = evaluator.evaluate("artifact_v1", ["artifact_v1:sensor.kitchen:{'temperature': 21.0}", "artifact_v1:sensor.bedroom:{'temperature': 18.5}"])
|
report = evaluator.evaluate(
|
||||||
|
"artifact_v1",
|
||||||
|
[
|
||||||
|
"artifact_v1:sensor.kitchen:{'temperature': 21.0}",
|
||||||
|
"artifact_v1:sensor.bedroom:{'temperature': 18.5}",
|
||||||
|
],
|
||||||
|
)
|
||||||
assert report.artifact_id == "artifact_v1"
|
assert report.artifact_id == "artifact_v1"
|
||||||
assert report.sample_size == 2
|
assert report.sample_size == 2
|
||||||
assert {metric.name for metric in report.metrics} == {"coverage", "unknown_rate"}
|
assert {metric.name for metric in report.metrics} == {"coverage", "unknown_rate"}
|
||||||
|
assert next(metric.value for metric in report.metrics if metric.name == "coverage") == 1.0
|
||||||
|
|
||||||
|
|
||||||
def test_evaluate_without_training_raises_value_error() -> None:
|
def test_evaluate_without_training_raises_value_error() -> None:
|
||||||
evaluator = Evaluator(TrainingPipeline(FeatureStore()))
|
evaluator = Evaluator(TrainingPipeline(FeatureStore()))
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
evaluator.evaluate("artifact_v1", [])
|
evaluator.evaluate("artifact_v1", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_is_bounded_and_requires_exact_sensor_match() -> None:
|
||||||
|
evaluator = evaluator_factory()
|
||||||
|
report = evaluator.evaluate(
|
||||||
|
"artifact_v1",
|
||||||
|
[
|
||||||
|
"artifact_v1:sensor.kitchen:{'note': 'sensor.bedroom'}",
|
||||||
|
"artifact_v1:sensor.kitchen_extra:{}",
|
||||||
|
"malformed",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = {metric.name: metric.value for metric in report.metrics}
|
||||||
|
assert metrics == {"coverage": pytest.approx(1 / 3), "unknown_rate": pytest.approx(2 / 3)}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.ml.feature_store import FeatureStore, FeatureVector
|
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||||
|
|
||||||
|
|
||||||
@@ -31,12 +29,18 @@ def test_add_batch_appends_all_vectors() -> None:
|
|||||||
]
|
]
|
||||||
store.add_batch(vectors)
|
store.add_batch(vectors)
|
||||||
assert len(store.all()) == 3
|
assert len(store.all()) == 3
|
||||||
assert store.latest("sensor.kitchen").values["temperature"] == 20.0
|
latest = store.latest("sensor.kitchen")
|
||||||
|
assert latest is not None
|
||||||
|
assert latest.values["temperature"] == 20.0
|
||||||
|
|
||||||
|
|
||||||
def test_different_sensors_are_stored_independently() -> None:
|
def test_different_sensors_are_stored_independently() -> None:
|
||||||
store = FeatureStore()
|
store = FeatureStore()
|
||||||
store.add(_vector("sensor.living_room", 21.0))
|
store.add(_vector("sensor.living_room", 21.0))
|
||||||
store.add(_vector("sensor.bedroom", 18.5))
|
store.add(_vector("sensor.bedroom", 18.5))
|
||||||
assert store.latest("sensor.living_room").values["temperature"] == 21.0
|
living_room = store.latest("sensor.living_room")
|
||||||
assert store.latest("sensor.bedroom").values["temperature"] == 18.5
|
bedroom = store.latest("sensor.bedroom")
|
||||||
|
assert living_room is not None
|
||||||
|
assert bedroom is not None
|
||||||
|
assert living_room.values["temperature"] == 21.0
|
||||||
|
assert bedroom.values["temperature"] == 18.5
|
||||||
|
|||||||
39
tests/ml/test_model_registry.py
Normal file
39
tests/ml/test_model_registry.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
|
from app.ml.training import TrainedArtifact
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_loads_persisted_artifacts_after_restart(tmp_path: Path) -> None:
|
||||||
|
registry = ModelRegistry(tmp_path)
|
||||||
|
artifact = TrainedArtifact("model-v1", ("sensor.kitchen", "sensor.bedroom"))
|
||||||
|
registry.register(artifact)
|
||||||
|
|
||||||
|
restarted = ModelRegistry(tmp_path)
|
||||||
|
|
||||||
|
assert restarted.load_artifact("model-v1") == artifact
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artifact_id", ["../escape", "nested/model", "..", ""])
|
||||||
|
def test_registry_rejects_unsafe_artifact_ids(tmp_path: Path, artifact_id: str) -> None:
|
||||||
|
registry = ModelRegistry(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
registry.register(TrainedArtifact(artifact_id, ("sensor.kitchen",)))
|
||||||
|
|
||||||
|
assert list(tmp_path.parent.glob("escape.json")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_rejects_corrupt_persisted_artifact(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "broken.json").write_text(
|
||||||
|
json.dumps({"artifact_id": "../broken", "supported_sensors": []}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="broken.json"):
|
||||||
|
ModelRegistry(tmp_path)
|
||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.ml.feature_store import FeatureStore, FeatureVector
|
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||||
from app.ml.training import TrainingPipeline, TrainedArtifact
|
from app.ml.training import TrainingPipeline
|
||||||
|
|
||||||
|
|
||||||
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
|
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
|
||||||
@@ -45,4 +45,4 @@ def test_export_returns_registered_artifact() -> None:
|
|||||||
def test_export_missing_artifact_raises_key_error() -> None:
|
def test_export_missing_artifact_raises_key_error() -> None:
|
||||||
pipeline = store_with_data()
|
pipeline = store_with_data()
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
pipeline.export("artifact_v1")
|
pipeline.export("artifact_v1")
|
||||||
|
|||||||
33
tests/ml/test_training_evaluation.py
Normal file
33
tests/ml/test_training_evaluation.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.ml.evaluation import Evaluator, EvalReport, Metric
|
||||||
|
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||||
|
from app.ml.training import TrainingPipeline
|
||||||
|
|
||||||
|
|
||||||
|
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
|
||||||
|
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_training_then_evaluation() -> None:
|
||||||
|
store = FeatureStore()
|
||||||
|
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
|
||||||
|
pipeline = TrainingPipeline(store)
|
||||||
|
artifact = pipeline.run("artifact_v1")
|
||||||
|
|
||||||
|
evaluator = Evaluator(pipeline)
|
||||||
|
predictions = [
|
||||||
|
"artifact_v1:sensor.kitchen:{'temperature': 21.0}",
|
||||||
|
"artifact_v1:sensor.bedroom:{'temperature': 18.5}",
|
||||||
|
]
|
||||||
|
report = evaluator.evaluate(artifact.artifact_id, predictions)
|
||||||
|
assert isinstance(report, EvalReport)
|
||||||
|
assert report.sample_size == len(predictions)
|
||||||
|
assert any(metric.name == "coverage" for metric in report.metrics)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_helpers_are_serializable() -> None:
|
||||||
|
metric = Metric(name="coverage", value=0.85, threshold=0.8)
|
||||||
|
assert metric.name == "coverage"
|
||||||
|
assert metric.value == 0.85
|
||||||
|
assert metric.threshold == 0.8
|
||||||
18
tests/test_config.py
Normal file
18
tests/test_config.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pytest import MonkeyPatch
|
||||||
|
|
||||||
|
from app.config import load_settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("SILLYHOME_HA_URL", "http://ha.local:8123")
|
||||||
|
monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret")
|
||||||
|
monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models")
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
assert settings.ha_url == "http://ha.local:8123"
|
||||||
|
assert settings.ha_token == "secret"
|
||||||
|
assert settings.model_store == "/tmp/models"
|
||||||
|
assert settings.ha_configured
|
||||||
Reference in New Issue
Block a user