harden delivery pipeline and production runtime
This commit is contained in:
@@ -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
|
||||||
|
|||||||
22
Dockerfile
22
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
|
||||||
|
|
||||||
|
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"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
40
README.md
40
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
|
||||||
```
|
```
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
15
app/main.py
15
app/main.py
@@ -1,4 +1,6 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -7,23 +9,30 @@ 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.ml.registry.model_registry import ModelRegistry
|
||||||
from backend.routes.ml import init_ml_routes
|
from backend.routes.ml import init_ml_routes
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
settings = app.state.settings
|
settings = app.state.settings
|
||||||
|
client: HaClient | None = None
|
||||||
|
app.state.registry = ModelRegistry(settings.model_store)
|
||||||
if hasattr(app.state, "ha_reader"):
|
if hasattr(app.state, "ha_reader"):
|
||||||
del app.state.ha_reader
|
del app.state.ha_reader
|
||||||
if settings.ha_configured:
|
if settings.ha_configured:
|
||||||
client = HaClient(
|
client = HaClient(
|
||||||
settings=HaClientSettings(
|
settings=HaClientSettings(
|
||||||
url=settings.ha_url,
|
url=cast(str, settings.ha_url),
|
||||||
token=settings.ha_token,
|
token=cast(str, settings.ha_token),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
app.state.ha_reader = HaReader(client=client)
|
app.state.ha_reader = HaReader(client=client)
|
||||||
|
try:
|
||||||
yield
|
yield
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ class ModelRegistry:
|
|||||||
|
|
||||||
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
|
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
|
||||||
self._validate_artifact_id(artifact.artifact_id)
|
self._validate_artifact_id(artifact.artifact_id)
|
||||||
self._artifacts[artifact.artifact_id] = artifact
|
|
||||||
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:
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
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,14 +10,20 @@ from app.ml.training import TrainingPipeline
|
|||||||
from app.ml.feature_store import FeatureStore, FeatureVector
|
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
@asynccontextmanager
|
||||||
application = FastAPI(title="SillyHome Next ML")
|
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
|
||||||
init_ml_routes(application)
|
application.state.registry = ModelRegistry(application.state.model_store)
|
||||||
_seed_default_model(application.state)
|
_seed_default_model(application.state)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
application = FastAPI(title="SillyHome Next ML", lifespan=lifespan)
|
||||||
|
init_ml_routes(application)
|
||||||
return application
|
return application
|
||||||
|
|
||||||
|
|
||||||
def _seed_default_model(state) -> None: # noqa: ANN001
|
def _seed_default_model(state: State) -> None:
|
||||||
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")
|
||||||
|
|||||||
1
backend/routes/__init__.py
Normal file
1
backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API route modules."""
|
||||||
@@ -2,7 +2,7 @@ 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, FastAPI, HTTPException, Request, status
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -24,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):
|
||||||
@@ -42,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)
|
||||||
@@ -82,7 +82,7 @@ def predict(payload: PredictRequest, request: Request) -> PredictResponse:
|
|||||||
def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
|
def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
|
||||||
registry = _require_registry(request)
|
registry = _require_registry(request)
|
||||||
predictor = Predictor(registry=registry)
|
predictor = Predictor(registry=registry)
|
||||||
responses: List[PredictResponse] = []
|
responses: list[PredictResponse] = []
|
||||||
for item in payload.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:
|
||||||
@@ -111,7 +111,6 @@ def _require_registry(request: Request) -> ModelRegistry:
|
|||||||
|
|
||||||
|
|
||||||
def init_ml_routes(app: FastAPI, model_store: str = ".model_store") -> None:
|
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
|
||||||
|
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
|
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,13 +107,15 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# ML Training- und Evaluations-Workflow
|
# 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
|
## 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.
|
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
|
```python
|
||||||
store = FeatureStore()
|
store = FeatureStore()
|
||||||
@@ -16,7 +18,9 @@ artifact = pipeline.run("my_artifact")
|
|||||||
pipeline.export("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
|
## 3. Modell evaluieren
|
||||||
|
|
||||||
@@ -37,3 +41,4 @@ Das trainierte Artefakt kann anschließend über `ModelRegistry.register(artifac
|
|||||||
## Hinweise
|
## Hinweise
|
||||||
- Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
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 health.status_code == 200
|
||||||
assert models.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:
|
def test_unknown_model_returns_404() -> None:
|
||||||
@@ -29,7 +31,7 @@ def test_unknown_model_returns_404() -> None:
|
|||||||
assert response.status_code == 404
|
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.registry.model_registry import ModelRegistry
|
||||||
from app.ml.training import TrainedArtifact
|
from app.ml.training import TrainedArtifact
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ from app.ml.registry.model_registry import ModelRegistry
|
|||||||
from app.ml.training import TrainedArtifact
|
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)
|
registry = ModelRegistry(tmp_path)
|
||||||
artifact = TrainedArtifact("model-v1", ("sensor.kitchen", "sensor.bedroom"))
|
artifact = TrainedArtifact("model-v1", ("sensor.kitchen", "sensor.bedroom"))
|
||||||
registry.register(artifact)
|
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", "..", ""])
|
@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)
|
registry = ModelRegistry(tmp_path)
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
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")) == []
|
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(
|
(tmp_path / "broken.json").write_text(
|
||||||
json.dumps({"artifact_id": "../broken", "supported_sensors": []}),
|
json.dumps({"artifact_id": "../broken", "supported_sensors": []}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pytest import MonkeyPatch
|
||||||
|
|
||||||
from app.config import load_settings
|
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_URL", "http://ha.local:8123")
|
||||||
monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret")
|
monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret")
|
||||||
monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models")
|
monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models")
|
||||||
|
|||||||
Reference in New Issue
Block a user