harden delivery pipeline and production runtime
This commit is contained in:
@@ -12,6 +12,5 @@ node_modules
|
||||
.vscode
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
@@ -1,2 +1,3 @@
|
||||
SILLYHOME_HA_URL=http://homeassistant.local:8123
|
||||
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
|
||||
- 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
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -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
|
||||
|
||||
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"]
|
||||
40
README.md
40
README.md
@@ -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
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
17
app/main.py
17
app/main.py
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
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 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")
|
||||
|
||||
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
|
||||
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")
|
||||
|
||||
@@ -2,7 +2,22 @@ services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "127.0.0.1:8000:8000"
|
||||
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
|
||||
|
||||
volumes:
|
||||
model-data:
|
||||
|
||||
@@ -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,13 +107,15 @@ 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,3 +41,4 @@ 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.
|
||||
- `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]
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user