harden delivery pipeline and production runtime
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-11 21:14:07 +02:00
parent 471146761e
commit aaf319ff14
26 changed files with 202 additions and 73 deletions

View File

@@ -12,6 +12,5 @@ node_modules
.vscode
.git
.gitignore
README.md
.dockerignore
docker-compose*.yml
docker-compose*.yml

View File

@@ -1,2 +1,3 @@
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

View 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

View File

@@ -3,3 +3,8 @@
## Unreleased
- Projektinitiierung
- 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

View File

@@ -1,15 +1,27 @@
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
COPY pyproject.toml ./
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir -e ".[dev]"
RUN addgroup --system sillyhome && adduser --system --ingroup sillyhome sillyhome
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
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"]

View File

@@ -1,6 +1,13 @@
# 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
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
- Erweiterbar, testbar, dokumentiert
## APPENDIX
### Quickstart
## Quickstart
1. Python-Venv anlegen und Abhängigkeiten installieren:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .
pip install -e ".[dev]"
```
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/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)
- `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`)
- `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
```bash
pytest -q
pytest
ruff check .
mypy app tests
```
mypy
```

View File

@@ -31,10 +31,13 @@ class HaClient:
"Content-Type": "application/json",
})
def close(self) -> None:
self._session.close()
def list_entities(self) -> list[dict[str, object]]:
try:
response = self._session.get(
f"{self._settings.url}/api/states",
f"{self._settings.url.rstrip('/')}/api/states",
timeout=self._settings.timeout_seconds,
)
except requests.Timeout as exc:
@@ -71,4 +74,4 @@ class HaClient:
"Antwort von Home Assistant hat unerwartetes Format."
)
return payload
return payload

View File

@@ -15,9 +15,10 @@ class HaReader:
entities = self._client.list_entities()
summaries: list[HaEntitySummary] = []
for item in entities:
entity_id = item.get("entity_id", "")
if "." not in entity_id:
raw_entity_id = item.get("entity_id")
if not isinstance(raw_entity_id, str) or "." not in raw_entity_id:
continue
entity_id = raw_entity_id
domain = entity_id.split(".", 1)[0]
raw_attributes = item.get("attributes") or {}
attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}

View File

@@ -1,4 +1,6 @@
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from typing import cast
from fastapi import FastAPI
@@ -7,23 +9,30 @@ from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry
from backend.routes.ml import init_ml_routes
@asynccontextmanager
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings
client: HaClient | None = None
app.state.registry = ModelRegistry(settings.model_store)
if hasattr(app.state, "ha_reader"):
del app.state.ha_reader
if settings.ha_configured:
client = HaClient(
settings=HaClientSettings(
url=settings.ha_url,
token=settings.ha_token,
url=cast(str, settings.ha_url),
token=cast(str, settings.ha_token),
)
)
app.state.ha_reader = HaReader(client=client)
yield
try:
yield
finally:
if client is not None:
client.close()
app = FastAPI(

View File

@@ -1,5 +1,5 @@
"""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.training import TrainedArtifact, TrainingPipeline

View File

@@ -1,8 +1,8 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Iterable
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass(frozen=True)
@@ -28,4 +28,4 @@ class FeatureStore:
return series[-1] if series else None
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]

View File

@@ -23,8 +23,8 @@ class ModelRegistry:
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
self._validate_artifact_id(artifact.artifact_id)
self._artifacts[artifact.artifact_id] = artifact
self._persist(artifact)
self._artifacts[artifact.artifact_id] = artifact
return artifact
def load_artifact(self, artifact_id: str) -> TrainedArtifact:

View File

@@ -2,9 +2,8 @@ from __future__ import annotations
import logging
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__)
@@ -34,4 +33,4 @@ class TrainingPipeline:
def export(self, artifact_id: str) -> TrainedArtifact:
if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht gefunden.")
return self._artifacts[artifact_id]
return self._artifacts[artifact_id]

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Secondary application entry points for SillyHome Next."""

View File

@@ -1,4 +1,8 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.datastructures import State
from backend.routes.ml import init_ml_routes
from app.ml.registry.model_registry import ModelRegistry
@@ -6,14 +10,20 @@ 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)
@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:
application = FastAPI(title="SillyHome Next ML", lifespan=lifespan)
init_ml_routes(application)
return application
def _seed_default_model(state) -> None: # noqa: ANN001
def _seed_default_model(state: State) -> None:
registry = getattr(state, "registry", None)
if registry is None:
registry = ModelRegistry(".model_store")

View File

@@ -0,0 +1 @@
"""API route modules."""

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import List, Sequence
from collections.abc import Sequence
from fastapi import APIRouter, FastAPI, HTTPException, Request, status
from pydantic import BaseModel, Field
@@ -24,7 +24,7 @@ class HealthResponse(BaseModel):
class PredictRequest(BaseModel):
model_id: str = Field(..., alias="modelId")
sensor_id: str
values: dict
values: dict[str, float]
class PredictResponse(BaseModel):
@@ -42,7 +42,7 @@ class BatchResponse(BaseModel):
class ModelsResponse(BaseModel):
models: List[str]
models: list[str]
@router.get("/health", response_model=HealthResponse, status_code=200)
@@ -82,7 +82,7 @@ def predict(payload: PredictRequest, request: Request) -> PredictResponse:
def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
registry = _require_registry(request)
predictor = Predictor(registry=registry)
responses: List[PredictResponse] = []
responses: list[PredictResponse] = []
for item in payload.requests:
vector = FeatureVector(sensor_id=item.sensor_id, values=item.values)
try:
@@ -111,7 +111,6 @@ def _require_registry(request: Request) -> ModelRegistry:
def init_ml_routes(app: FastAPI, model_store: str = ".model_store") -> None:
registry = ModelRegistry(model_store)
app.state.registry = registry
app.state.model_store = model_store
app.include_router(router)
logger.info("ML routes registered")

View File

@@ -2,7 +2,22 @@ services:
api:
build: .
ports:
- "8000:8000"
- "127.0.0.1:8000:8000"
env_file:
- .env
restart: unless-stopped
- path: .env
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:

View File

@@ -1,6 +1,11 @@
# 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
@@ -10,7 +15,8 @@ Diese Dokumentation beschreibt die REST-Endpoints für ML-Vorhersagen in SillyHo
- Einzelvorhersage: `/predict`
- 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
@@ -101,16 +107,18 @@ Batch-Vorhersage für mehrere Sensorwerte.
## 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.
- `404 Not Found`: Modell nicht registriert.
- `422 Unprocessable Content`: Sensor wird vom Modell nicht unterstützt oder Eingabe ist ungültig.
- `503 Service Unavailable`: Registry ist nicht initialisiert.
## 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
- `app/ml/predictor.py`
- `app/ml/registry/model_registry.py`
- `backend/routes/ml.py`
- `backend/routes/ml.py`

View File

@@ -1,12 +1,14 @@
# ML Training- und Evaluations-Workflow
Dieser Workflow beschreibt, wie Modelle trainiert, evaluiert und an der Serving-Layer registriert werden.
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. Modell trainieren
## 2. Artefakt-Metadaten erzeugen
```python
store = FeatureStore()
@@ -16,7 +18,9 @@ artifact = pipeline.run("my_artifact")
pipeline.export("my_artifact")
```
`TrainingPipeline.run(...)` erzeugt ein `TrainedArtifact` mit den unterstützten Sensor-IDs.
`TrainingPipeline.run(...)` erzeugt ein `TrainedArtifact` mit den unterstützten
Sensor-IDs. Gewichte, Parameter oder ein echtes Modell werden noch nicht
berechnet.
## 3. Modell evaluieren
@@ -36,4 +40,5 @@ Das trainierte Artefakt kann anschließend über `ModelRegistry.register(artifac
## 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.
- 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.

View File

@@ -1,3 +1,7 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-next"
version = "0.1.0"
@@ -24,6 +28,10 @@ addopts = "-q"
[tool.mypy]
strict = true
files = ["app", "backend", "tests"]
[tool.setuptools.packages.find]
include = ["app*", "backend*"]
[tool.ruff]
line-length = 100

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import app
@@ -12,7 +14,7 @@ def test_ml_routes_are_exposed_by_production_app() -> None:
assert health.status_code == 200
assert models.status_code == 200
assert models.json() == {"models": []}
assert isinstance(models.json()["models"], list)
def test_unknown_model_returns_404() -> None:
@@ -29,7 +31,7 @@ def test_unknown_model_returns_404() -> None:
assert response.status_code == 404
def test_unsupported_sensor_returns_422(tmp_path) -> None:
def test_unsupported_sensor_returns_422(tmp_path: Path) -> None:
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainedArtifact

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
@@ -31,12 +29,18 @@ def test_add_batch_appends_all_vectors() -> None:
]
store.add_batch(vectors)
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:
store = FeatureStore()
store.add(_vector("sensor.living_room", 21.0))
store.add(_vector("sensor.bedroom", 18.5))
assert store.latest("sensor.living_room").values["temperature"] == 21.0
assert store.latest("sensor.bedroom").values["temperature"] == 18.5
living_room = store.latest("sensor.living_room")
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

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
@@ -8,7 +9,7 @@ from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainedArtifact
def test_registry_loads_persisted_artifacts_after_restart(tmp_path) -> None:
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)
@@ -19,7 +20,7 @@ def test_registry_loads_persisted_artifacts_after_restart(tmp_path) -> None:
@pytest.mark.parametrize("artifact_id", ["../escape", "nested/model", "..", ""])
def test_registry_rejects_unsafe_artifact_ids(tmp_path, artifact_id: str) -> None:
def test_registry_rejects_unsafe_artifact_ids(tmp_path: Path, artifact_id: str) -> None:
registry = ModelRegistry(tmp_path)
with pytest.raises(ValueError):
@@ -28,7 +29,7 @@ def test_registry_rejects_unsafe_artifact_ids(tmp_path, artifact_id: str) -> Non
assert list(tmp_path.parent.glob("escape.json")) == []
def test_registry_rejects_corrupt_persisted_artifact(tmp_path) -> None:
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",

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import pytest
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:
@@ -45,4 +45,4 @@ def test_export_returns_registered_artifact() -> None:
def test_export_missing_artifact_raises_key_error() -> None:
pipeline = store_with_data()
with pytest.raises(KeyError):
pipeline.export("artifact_v1")
pipeline.export("artifact_v1")

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
from pytest import MonkeyPatch
from app.config import load_settings
def test_load_settings_reads_documented_environment(monkeypatch) -> None:
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")