From 1e2d7fc8c47729f9265fb919e5a94e2bf734aa62 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Mon, 24 Aug 2026 22:31:20 -0600 Subject: [PATCH 1/3] feat(excita): microWakeWord + nanoWakeWord engines (#213) Two new first-class engines behind the WakeWordEngine protocol, per the spec 0011 extension and ADR-0020..0023: - microwakeword: score (tflite-runtime over stored clips) + package (tflite_micro); load/feed/train raise NotSupportedError with honest messages (detection lives on the ESP32; train waits on the worker). - nanowakeword: load/feed/score/package via NanoInterpreter, residual- buffered detector matching the openWakeWord adapter shape; train points at EXCITA_TRAIN_WORKER_URL. - GET /engines capability matrix; capability gaps return structured 501 {code: engine_capability_missing, engine, capability, message} on every dispatching route, declared in OpenAPI. - Model data model: models table with UNIQUE(phrase_id, engine, version), source upload|filesystem, filesystem_path, metrics envelope+raw; phrases gain notes/deleted_at via additive migration. - POST /models/import (artifact + metadata sidecar) and a filesystem scanner over EXCITA_MODEL_IMPORT_DIR (boot, SIGHUP, POST /models/scan): new files import, missing files soft-delete, version bumps mint new rows, unchanged re-scans are no-ops; DELETE on filesystem rows is 409 filesystem_imported_read_only (ADR-0021). - Deploy targets (file, http_push with X-Excita-* headers, linked_service_config) with publish-as-row-change semantics; OWW package(onnx) so all three transports work for every real engine. - POST /debug/score and POST /train wired through the same 501 contract. Tests at the TestClient(create_app) seam: capability matrix cells, phrase-across-engines, sidecar round-trip, scanner lifecycle, deploy publish (real local HTTP server for header capture), plus artifact-gated uWW/nWW adapter tests. Spec 0011 header + open questions updated. --- docs/specs/0011-excita-wake-word-ops.md | 4 +- services/excita/Dockerfile | 6 +- services/excita/README.md | 70 ++- services/excita/app.py | 606 ++++++++++++++++++- services/excita/backend.py | 328 ++++++++++- services/excita/engines/__init__.py | 24 +- services/excita/engines/base.py | 26 + services/excita/engines/microwakeword.py | 108 ++++ services/excita/engines/nanowakeword.py | 151 +++++ services/excita/engines/openwakeword.py | 19 +- services/excita/model_import.py | 254 ++++++++ services/excita/requirements.txt | 8 + services/excita/test_app.py | 721 ++++++++++++++++++++++- 13 files changed, 2280 insertions(+), 45 deletions(-) create mode 100644 services/excita/engines/microwakeword.py create mode 100644 services/excita/engines/nanowakeword.py create mode 100644 services/excita/model_import.py diff --git a/docs/specs/0011-excita-wake-word-ops.md b/docs/specs/0011-excita-wake-word-ops.md index 7c266d8..6b0a214 100644 --- a/docs/specs/0011-excita-wake-word-ops.md +++ b/docs/specs/0011-excita-wake-word-ops.md @@ -2,6 +2,8 @@ Draft. Excita is Conduit's **wake-word service**: one process that both **runs wake-word detection** on live audio and provides the **ops plane** (labelling, debugging, training, configuring) that makes the models it runs better over time. Ops and detection live together on purpose — they share the engine adapters, they share the model store, and unifying them is what closes the data loop without an operator shipping files by scp. +> **Extended by [constructorfleet/conduit#213](https://github.com/constructorfleet/conduit/issues/213)** (Excita: microWakeWord + nanoWakeWord engines — capability contract, model import, deploy targets). Anchored by [ADR-0020](../adr/0020-wake-engine-adapters-are-partial.md), [ADR-0021](../adr/0021-filesystem-imported-models-are-read-only-in-ui.md), [ADR-0022](../adr/0022-phrase-is-engine-agnostic.md), [ADR-0023](../adr/0023-engine-capability-gaps-return-501.md); where the issue speaks, it supersedes the sketches below. + Other runtimes still exist (openWakeWord baked into a satellite, microWakeWord on an ESPHome device, `crates/conduit-wake` in Conduit itself). Excita is one of them; it is *also* the tool that trains their models and, via a shared engine-agnostic package format, can publish updates to them. Anchors: [0005](0005-link-protocol.md) (link protocol), [0007](0007-excita-wake-events-side-channel.md) (`excita.wake-events` — Excita **is** the sender when it is the detector), [0010](0010-linked-service-lifecycle-and-dev.md) (linked-service lifecycle), [0004](0004-embedded-service-visual-consistency.md) (embedded panel visual consistency). Reference implementation shape: `services/instrumenta/`. @@ -255,4 +257,4 @@ Follow-up PRs (each its own review): - **Multi-operator labelling.** The schema supports `(clip, labeller)` but the UI is single-operator for v1. Do we need reconciliation UX when two operators disagree? Deferred until a second operator exists. - **Clip retention beyond the delete window.** Legal-hold on a clip an operator wants to keep forever? Add a `pinned` boolean on `clip` when this comes up. -- **Training compute.** In-process is fine for openWakeWord on a laptop. microWakeWord's TF training will not be. Escape hatch is `EXCITA_TRAIN_WORKER_URL` — punt to an external worker if set — but not built until asked. +- **Training compute.** In-process is fine for openWakeWord on a laptop. microWakeWord's TF training will not be. Escape hatch is `EXCITA_TRAIN_WORKER_URL` — punt to an external worker if set — but not built until asked. **[#213](https://github.com/constructorfleet/conduit/issues/213)** lands µWW / nanoWakeWord with `train` → `NotSupportedError` pointing at that variable; the training worker protocol (queue semantics, artifact upload, credentials, cancellation) remains its own future spec. diff --git a/services/excita/Dockerfile b/services/excita/Dockerfile index 245861f..6016016 100644 --- a/services/excita/Dockerfile +++ b/services/excita/Dockerfile @@ -14,11 +14,15 @@ RUN pip install --no-cache-dir -r requirements.txt \ COPY services/excita ./excita ENV EXCITA_DATA_DIR=/data \ - EXCITA_WAKE_MODELS_DIR=/wake-models + EXCITA_WAKE_MODELS_DIR=/wake-models \ + EXCITA_MODEL_IMPORT_DIR=/model-import # `/data` holds SQLite + clips (write-heavy); `/wake-models` holds the two # shared openWakeWord ONNX files + any per-phrase classifier — read-only in # production, populated by `scripts/fetch-wake-models.sh` or a bind mount. # Separate volume so an image update doesn't rewrite fetched models. +# `/model-import` is the pre-trained model drop point (spec 0013): scanned +# on boot and on SIGHUP, the volume is the source of truth for anything +# mounted there (ADR-0021). VOLUME ["/data", "/wake-models"] EXPOSE 8084 diff --git a/services/excita/README.md b/services/excita/README.md index bd9f1d6..523f4d4 100644 --- a/services/excita/README.md +++ b/services/excita/README.md @@ -2,7 +2,59 @@ Wake-word **operations service** — label, debug, train, and configure wake-word models. Not the runtime detector; runtime detectors POST clips into Excita. -See [`docs/specs/0011-excita-wake-word-ops.md`](../../docs/specs/0011-excita-wake-word-ops.md). +See [`docs/specs/0011-excita-wake-word-ops.md`](../../docs/specs/0011-excita-wake-word-ops.md) +and [issue #213](https://github.com/constructorfleet/conduit/issues/213) for the +microWakeWord / nanoWakeWord extension. + +## Engines + +Each engine honestly declares which capabilities it implements +([ADR-0020](../../docs/adr/0020-wake-engine-adapters-are-partial.md)); +`GET /engines` returns the matrix, and asking for a missing capability +returns a structured `501` with `{code: "engine_capability_missing", engine, +capability, message}` ([ADR-0023](../../docs/adr/0023-engine-capability-gaps-return-501.md)): + +| Engine | load/feed | score | train | package | +|----------------|-----------|-------|-------|---------------| +| openWakeWord | yes¹ | yes | — | `onnx` | +| nanoWakeWord | yes | yes | —² | `onnx` | +| microWakeWord | —³ | yes | —² | `tflite_micro`| +| Porcupine | adapter not landed yet (all gaps → 501) | + +¹ Requires the shared ONNX models (`scripts/fetch-wake-models.sh`); otherwise a null slot answers 501. +² Training waits on the `EXCITA_TRAIN_WORKER_URL` worker protocol (future spec). +³ microWakeWord detects on the ESP32; Excita scores stored clips offline and packages for flash. + +Phrases are engine-agnostic ([ADR-0022](../../docs/adr/0022-phrase-is-engine-agnostic.md)): +one "hey jarvis" carries models across engines, so cross-engine comparison is +one phrase row with several model rows. + +## Model import + +Two paths land models in Excita: + +- **Upload**: `POST /models/import` (multipart artifact + JSON `metadata` + form field: `engine`, `phrase_name`, `version`, optional + `engine_phrase_key` / `metrics_json` / `notes`). Unknown phrases are + created; an `.excita.json` sidecar is written next to the stored artifact. +- **Filesystem drop**: bind-mount a directory at `EXCITA_MODEL_IMPORT_DIR`. + Scanned on boot and on `SIGHUP`; `POST /models/scan` triggers a scan too. + Each `.excita.json` sidecar describes its artifact. The volume + is the source of truth: new files appear, removed files disappear, + sidecar `version` bumps mint new model rows (history preserved), and + re-saving without a bump changes nothing. + [ADR-0021](../../docs/adr/0021-filesystem-imported-models-are-read-only-in-ui.md): + filesystem-imported models cannot be deleted through the API (`409`, + `code: "filesystem_imported_read_only"`) — remove the file instead. + +## Deploy targets + +Three transports (`file`, `http_push`, `linked_service_config`) work for every +engine's native package. Publishing is one call: +`POST /deploy_targets/{id}/publish {"model_id": ...}` sets the target's current +model and pushes; a failed push never rolls back the selection (retry the same +call). `http_push` sends headers `X-Excita-Engine`, `X-Excita-Phrase`, +`X-Excita-Version`. ## Run locally @@ -30,4 +82,18 @@ in `static/index.html`. PYTHONPATH=.. pytest ``` -Detection tests skip when the fetched models are missing. +Detection tests skip when the fetched models are missing. The µWW / nanoWakeWord +adapter tests skip unless their artifacts are dropped next to the openWakeWord +ones (`hey_jarvis_v0.1.tflite`, `hey_jarvis_v0.1.nww.onnx`). + +## Environment + +| Variable | Default | Meaning | +|---|---|---| +| `EXCITA_DATA_DIR` | `/data` | SQLite, clips, uploaded model artifacts | +| `EXCITA_BACKEND` | `sqlite` | Backend type | +| `EXCITA_BASE_URL` | `http://localhost:8084` | Advertised link URL | +| `EXCITA_WAKE_MODELS_DIR` | `/wake-models` | Shared openWakeWord ONNX files | +| `EXCITA_MODEL_IMPORT_DIR` | unset | Filesystem model-import mount (scanner) | +| `EXCITA_PREROLL_MS` | `2000` | Per-source pre-roll ring buffer | +| `EXCITA_TRAIN_WORKER_URL` | referenced in 501 messages | Training worker (future spec) | diff --git a/services/excita/app.py b/services/excita/app.py index 7ec4b0d..a531194 100644 --- a/services/excita/app.py +++ b/services/excita/app.py @@ -1,32 +1,38 @@ """Conduit Excita — the wake-word operations service. -Skeleton per spec 0011. Ships with: +Per spec 0011 and its µWW / nWW extension (#213). Ships with: -- `POST /phrases`, `GET /phrases` +- `POST /phrases`, `GET /phrases`, `GET /phrases/{id}` (models across engines) - `POST /clips` (multipart upload; browser record uses the same endpoint) - `GET /clips` filtered by phrase and verdict (including `unlabeled`) -- `POST /clips/{id}/label` -- `GET /clips/{id}/audio` for playback +- `POST /clips/{id}/label`, `GET /clips/{id}/audio` for playback +- `POST /models/import` + filesystem scanner over `EXCITA_MODEL_IMPORT_DIR` + (`GET`/`DELETE /models`, `POST /models/scan`) +- `GET /engines` — capability matrix per engine (ADR-0020) +- Engine dispatch with structured 501s for capability gaps (ADR-0023): + `POST /detectors` (load), `POST /debug/score` (score), `POST /train` + (train), deploy-target publish (package) +- `POST /deploy_targets` + publish through `file`, `http_push`, + `linked_service_config` - `GET /health` (link-health) and `GET /ready` - `/link` router from `conduit-link` (0005/0010 shape) - -Training and deploy surfaces are defined in the spec but not implemented in -the scaffold — the null engine adapter raises `NotSupportedError` if wired up -so a call site never mistakes silence for success. """ from __future__ import annotations +import json import logging import os +import re import wave from contextlib import asynccontextmanager from datetime import datetime, timezone from io import BytesIO from pathlib import Path +import httpx from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import RedirectResponse, Response +from fastapi.responses import JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -40,14 +46,27 @@ make_link_router, ) -from .backend import Backend, Clip, Label, Phrase, SqliteBackend, new_id +from .backend import ( + Backend, + Clip, + DeployTarget, + Label, + Model, + Phrase, + SqliteBackend, + new_id, +) from .clip_store import ClipStore, UnsupportedMimeError +from .model_import import ModelImporter from .engines import ( EngineKind, + MicroWakeWordEngine, + NanoWakeWordEngine, NotSupportedError, NullEngine, OpenWakeWordEngine, WakeWordEngine, + capability_view, ) from .supervisor import DetectorSupervisor, bindings_view @@ -107,18 +126,23 @@ class Config(BaseModel): # 0011 §Non-goals: replacing engine-specific tooling. wake_models_dir: Path | None = None pre_roll_ms: int = 2000 + # Bind-mounted directory of pre-trained models scanned on boot and on + # SIGHUP (#213 §Model import). Unset disables filesystem import. + model_import_dir: Path | None = None @classmethod def from_env(cls) -> "Config": data_dir = Path(os.getenv("EXCITA_DATA_DIR", "/data")) wake_env = os.getenv("EXCITA_WAKE_MODELS_DIR") wake_dir = Path(wake_env) if wake_env else data_dir / "wake-models" + import_env = os.getenv("EXCITA_MODEL_IMPORT_DIR") return cls( data_dir=data_dir, backend_type=os.getenv("EXCITA_BACKEND", "sqlite"), base_url=os.getenv("EXCITA_BASE_URL", f"http://localhost:{DEFAULT_PORT}"), wake_models_dir=wake_dir, pre_roll_ms=int(os.getenv("EXCITA_PREROLL_MS", "2000")), + model_import_dir=Path(import_env) if import_env else None, ) @@ -146,6 +170,26 @@ class PhraseOut(BaseModel): language: str +class ModelOut(BaseModel): + id: str + phrase_id: str + engine: str + version: str + engine_phrase_key: str | None + source: str + filesystem_path: str | None + artifact_path: str + metrics: dict[str, object] + notes: str | None + created_at: str + + +class PhraseDetailOut(PhraseOut): + """A phrase plus its models across every engine (ADR-0022).""" + + models: list[ModelOut] + + class ClipOut(BaseModel): id: str phrase_id: str @@ -196,6 +240,43 @@ class ArmDetectorIn(BaseModel): threshold: float | None = None +class TrainIn(BaseModel): + phrase_id: str + engine: str + base_model_id: str | None = None + + +class DebugScoreIn(BaseModel): + clip_id: str + model_id: str | None = None # null = every active model on the clip's phrase + + +class ScoreResultOut(BaseModel): + model_id: str + engine: str + curve: list[float] + + +class DeployTargetIn(BaseModel): + kind: str + config: dict[str, object] + + +class PublishIn(BaseModel): + model_id: str + + +class DeployTargetOut(BaseModel): + id: str + kind: str + config: dict[str, object] + current_model_id: str | None + last_publish_at: str | None + last_publish_status: str | None + last_publish_error: str | None + created_at: str + + class WakeEventOut(BaseModel): """Local ring-buffer entry (spec 0011 §Standalone posture).""" @@ -208,16 +289,21 @@ class WakeEventOut(BaseModel): def _default_engines(config: Config) -> dict[EngineKind, WakeWordEngine]: - """Real engine where models are available, `NullEngine` otherwise. - - openWakeWord gets a real adapter iff the two shared ONNX models are - present at boot. When they're not, the slot stays a `NullEngine` so - the API answers with a 501 naming the missing capability rather than - a 404 or a crash — spec 0011's "honest gap, not a stub" contract. + """Real engine where the runtime is a hard dep, `NullEngine` otherwise. + + nanoWakeWord and microWakeWord adapters are real unconditionally — + their packages ship in the image (#213 §Dependencies: one image, + one behavior). openWakeWord gets a real adapter iff its two shared + ONNX models are present at boot; when they're not, the slot stays a + `NullEngine` so the API answers with an honest gap rather than a 404 + or a crash. Porcupine has no adapter yet. """ engines: dict[EngineKind, WakeWordEngine] = { - kind: NullEngine(kind) for kind in EngineKind + EngineKind.MICROWAKEWORD: MicroWakeWordEngine(), + EngineKind.NANOWAKEWORD: NanoWakeWordEngine(), } + for kind in (EngineKind.OPENWAKEWORD, EngineKind.PORCUPINE): + engines[kind] = NullEngine(kind) wake_dir = config.wake_models_dir if wake_dir is not None: melspec = wake_dir / "melspectrogram.onnx" @@ -276,6 +362,25 @@ def _now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") +def _capability_missing( + kind: EngineKind, capability: str, error: NotSupportedError +) -> JSONResponse: + """Structured 501 body for engine capability gaps (ADR-0023). + + The frontend keys on `code` and renders `message` as a tooltip; it + never parses error text to figure out what an engine can't do. + """ + return JSONResponse( + status_code=501, + content={ + "code": "engine_capability_missing", + "engine": kind.value, + "capability": capability, + "message": str(error), + }, + ) + + def create_app(config: Config | None = None) -> FastAPI: if config is None: config = Config.from_env() @@ -283,6 +388,12 @@ def create_app(config: Config | None = None) -> FastAPI: config.data_dir.mkdir(parents=True, exist_ok=True) backend = _make_backend(config) clip_store = ClipStore(config.data_dir / "clips") + models_dir = config.data_dir / "models" + importer = ( + ModelImporter(backend, config.model_import_dir) + if config.model_import_dir is not None + else None + ) engines = _default_engines(config) supervisor = DetectorSupervisor( backend=backend, @@ -304,6 +415,15 @@ async def lifespan(app: FastAPI): app.state.engines = engines app.state.supervisor = supervisor app.state.config = config + if importer is not None: + # Boot scan: a first-boot deployment comes up with usable wake + # words before an operator ever opens the UI (#213). + result = importer.scan() + LOG.info( + "model import scan imported=%d updated=%d removed=%d errors=%d", + len(result.imported_ids), len(result.updated_ids), + len(result.removed_ids), result.errors, + ) yield await backend.close() @@ -313,6 +433,9 @@ async def lifespan(app: FastAPI): version="0.1.0", lifespan=lifespan, ) + # Set outside the lifespan too so `python -m excita.app` can reach the + # importer for its SIGHUP handler before uvicorn starts serving. + app.state.model_importer = importer link_config = LinkConfig( service_kind=LinkedServiceKind.EXCITA, @@ -369,6 +492,220 @@ async def create_phrase(body: PhraseIn) -> PhraseOut: raise HTTPException(409, f"phrase exists: {name}") from error return PhraseOut(**phrase.__dict__) + @app.get("/phrases/{phrase_id}") + async def get_phrase(phrase_id: str) -> PhraseDetailOut: + """Phrase detail with its models across every engine — the + cross-engine comparison view is the point of the tool (ADR-0022).""" + phrase = backend.get_phrase(phrase_id) + if phrase is None: + raise HTTPException(404, f"phrase not found: {phrase_id}") + return PhraseDetailOut( + **phrase.__dict__, + models=[ + _model_out(m) for m in backend.list_models(phrase_id=phrase_id) + ], + ) + + # --- models (#213 §Model import / §Data model) --- + + @app.get("/models") + async def list_models( + phrase_id: str | None = None, + source: str | None = None, + ) -> list[ModelOut]: + if source is not None and source not in {"upload", "filesystem"}: + raise HTTPException(422, f"invalid source filter: {source}") + return [ + _model_out(m) for m in backend.list_models(phrase_id=phrase_id, source=source) + ] + + @app.post("/models/import", status_code=201) + async def import_model( + metadata: str = Form(...), + file: UploadFile = File(...), + ) -> ModelOut: + try: + meta = json.loads(metadata) + except json.JSONDecodeError as error: + raise HTTPException(422, f"metadata is not valid JSON: {error}") from error + if not isinstance(meta, dict): + raise HTTPException(422, "metadata must be a JSON object") + + engine_value = meta.get("engine") + try: + engine_kind = EngineKind(engine_value) + except ValueError as error: + raise HTTPException(422, f"unknown engine: {engine_value}") from error + + phrase_name = str(meta.get("phrase_name") or "").strip() + if not phrase_name: + raise HTTPException(422, "phrase_name must not be blank") + version = str(meta.get("version") or "").strip() + if not version: + raise HTTPException(422, "version must not be blank") + + metrics = meta.get("metrics_json") or {} + if not isinstance(metrics, dict): + raise HTTPException(422, "metrics_json must be a JSON object") + notes = meta.get("notes") + if notes is not None and not isinstance(notes, str): + raise HTTPException(422, "notes must be a string") + + data = await file.read() + if not data: + raise HTTPException(422, "empty upload") + + filename = Path(file.filename or "").name + if not filename: + raise HTTPException(422, "filename must not be blank") + + phrase = backend.get_phrase_by_name(phrase_name) + if phrase is None: + phrase = Phrase( + id=new_id(), name=phrase_name, + display_label=phrase_name, language="en", + ) + try: + backend.insert_phrase(phrase) + except Exception as error: + raise HTTPException(409, f"phrase exists: {phrase_name}") from error + + models_dir.mkdir(parents=True, exist_ok=True) + artifact_path = models_dir / filename + if artifact_path.exists(): + # Excita owns the artifact going forward — never overwrite. + artifact_path = models_dir / f"{new_id()}-{filename}" + artifact_path.write_bytes(data) + + engine_phrase_key = meta.get("engine_phrase_key") + model = Model( + id=new_id(), + phrase_id=phrase.id, + engine=engine_kind.value, + version=version, + engine_phrase_key=( + engine_phrase_key + if isinstance(engine_phrase_key, str) and engine_phrase_key + else artifact_path.stem + ), + source="upload", + filesystem_path=None, + artifact_path=str(artifact_path), + metrics_json=json.dumps(metrics), + notes=notes, + file_mtime=None, + file_size=len(data), + created_at=_now_iso(), + deleted_at=None, + ) + try: + backend.insert_model(model) + except Exception as error: + artifact_path.unlink(missing_ok=True) + raise HTTPException( + 409, + f"model exists for (phrase, engine, version): " + f"{phrase_name}/{engine_kind.value}/{version}", + ) from error + + # Sidecar next to the artifact: copying it into the scanner mount + # promotes this model to a filesystem-imported one without a + # rewrite step (#213 story 15). + sidecar_path = artifact_path.with_name(artifact_path.name + ".excita.json") + sidecar_path.write_text(json.dumps({ + "engine": engine_kind.value, + "phrase_name": phrase_name, + "version": version, + "engine_phrase_key": model.engine_phrase_key, + "metrics_json": metrics, + "notes": notes, + })) + return _model_out(model) + + @app.get("/models/{model_id}") + async def get_model(model_id: str) -> ModelOut: + model = backend.get_model(model_id) + if model is None: + raise HTTPException(404, f"model not found: {model_id}") + return _model_out(model) + + @app.delete("/models/{model_id}", status_code=204) + async def delete_model(model_id: str) -> Response: + model = backend.get_model(model_id) + if model is None: + raise HTTPException(404, f"model not found: {model_id}") + if model.source == "filesystem": + # ADR-0021: the volume is the source of truth. UI-deleting would + # let the next scan resurrect it; retiring means removing the + # file on disk. + return JSONResponse( + status_code=409, + content={ + "code": "filesystem_imported_read_only", + "message": ( + "filesystem-imported models cannot be deleted through " + "the API; remove the file from the import mount instead" + ), + "filesystem_path": model.filesystem_path, + }, + ) + backend.soft_delete_model(model_id) + return Response(status_code=204) + + @app.post("/models/scan") + async def scan_models() -> dict[str, object]: + if importer is None: + raise HTTPException(409, "EXCITA_MODEL_IMPORT_DIR is not configured") + result = importer.scan() + return { + "imported_ids": result.imported_ids, + "updated_ids": result.updated_ids, + "removed_ids": result.removed_ids, + "errors": result.errors, + } + + # --- debug scoring (spec 0011 §Debug) --- + + @app.post("/debug/score", + responses={501: {"description": "engine capability missing"}}) + async def debug_score(body: DebugScoreIn) -> list[ScoreResultOut]: + clip = backend.get_clip(body.clip_id) + if clip is None: + raise HTTPException(404, f"clip not found: {body.clip_id}") + if body.model_id is not None: + model = backend.get_model(body.model_id) + if model is None: + raise HTTPException(404, f"model not found: {body.model_id}") + targets = [model] + else: + # No model named → every active model on the clip's phrase, so + # a cross-engine regression check is one call (#213 story 8). + targets = backend.list_models(phrase_id=clip.phrase_id) + if not targets: + raise HTTPException( + 404, f"no active models registered for phrase: {clip.phrase_id}" + ) + + audio = clip_store.read(clip.stored_path) + results: list[ScoreResultOut] = [] + for model in targets: + kind = EngineKind(model.engine) + engine = engines[kind] + try: + curve = engine.score(audio, model.artifact_path) + except NotSupportedError as error: + return _capability_missing(kind, "score", error) + except FileNotFoundError as error: + raise HTTPException(404, str(error)) from error + except ValueError as error: + raise HTTPException(422, str(error)) from error + results.append(ScoreResultOut( + model_id=model.id, engine=model.engine, curve=curve, + )) + return results + + # --- deploy targets (spec 0011 §Configure & publish, #213) --- + # --- clips --- @app.post("/clips", status_code=201) @@ -470,14 +807,42 @@ async def label_clip(clip_id: str, body: LabelIn) -> LabelOut: backend.upsert_label(label) return LabelOut(**label.__dict__) + # --- engines (#213 §Capability contract) --- + + @app.get("/engines") + async def list_engines() -> list[dict[str, object]]: + """Capability matrix so the UI grays out unsupported controls + before the operator clicks them — the 501s are the belt to this + suspenders (ADR-0023 §Consequences).""" + return [capability_view(engine) for engine in engines.values()] + + @app.post("/train", + response_model=None, + responses={501: {"description": "engine capability missing"}}) + async def train(body: TrainIn) -> dict[str, object] | JSONResponse: + try: + kind = EngineKind(body.engine) + except ValueError as error: + raise HTTPException(422, f"unknown engine: {body.engine}") from error + if backend.get_phrase(body.phrase_id) is None: + raise HTTPException(404, f"phrase not found: {body.phrase_id}") + engine = engines[kind] + try: + job_id = engine.train(f"{body.phrase_id}:{_now_iso()}", body.base_model_id) + except NotSupportedError as error: + return _capability_missing(kind, "train", error) + return {"job_id": job_id} + # --- detection surface (spec 0011 §Runtime detection loop) --- @app.get("/detectors") async def list_detectors() -> list[DetectorOut]: return [DetectorOut(**row) for row in bindings_view(supervisor.list_bindings())] - @app.post("/detectors", status_code=201) - async def arm_detector(body: ArmDetectorIn) -> DetectorOut: + @app.post("/detectors", status_code=201, + response_model=None, + responses={501: {"description": "engine capability missing"}}) + async def arm_detector(body: ArmDetectorIn) -> DetectorOut | JSONResponse: try: kind = EngineKind(body.engine) except ValueError as error: @@ -490,11 +855,10 @@ async def arm_detector(body: ArmDetectorIn) -> DetectorOut: if kind is EngineKind.OPENWAKEWORD \ else engine.load(body.model_ref, body.phrase_id) # type: ignore[call-arg] except NotSupportedError as error: - # An engine slot that stayed `NullEngine` at boot is what - # happens when its model files are missing. Reporting the - # engine's own message keeps the operator's diagnostic honest - # (spec 0011: honest gap, not a stub). - raise HTTPException(501, str(error)) from error + # A capability gap (null slot or partial adapter — ADR-0020) + # is "the server cannot ever fulfil this", not a bad payload: + # structured 501 per ADR-0023. + return _capability_missing(kind, "load", error) except FileNotFoundError as error: raise HTTPException(404, str(error)) from error except Exception as error: # noqa: BLE001 @@ -547,6 +911,73 @@ async def recent_wake_events(limit: int = 64) -> list[WakeEventOut]: for e in supervisor.recent_events(limit) ] + @app.get("/deploy_targets") + async def list_deploy_targets() -> list[DeployTargetOut]: + return [_target_out(t) for t in backend.list_deploy_targets()] + + @app.get("/deploy_targets/{target_id}") + async def get_deploy_target(target_id: str) -> DeployTargetOut: + target = backend.get_deploy_target(target_id) + if target is None: + raise HTTPException(404, f"deploy target not found: {target_id}") + return _target_out(target) + + @app.post("/deploy_targets", status_code=201) + async def create_deploy_target(body: DeployTargetIn) -> DeployTargetOut: + if body.kind not in {"file", "http_push", "linked_service_config"}: + raise HTTPException(422, f"unknown deploy target kind: {body.kind}") + required = {"file": "directory", "http_push": "url"} + missing_key = required.get(body.kind) + if missing_key and not str(body.config.get(missing_key) or "").strip(): + raise HTTPException( + 422, f"deploy target kind '{body.kind}' requires config.{missing_key}" + ) + target = DeployTarget( + id=new_id(), + kind=body.kind, + config_json=json.dumps(body.config), + current_model_id=None, + last_publish_at=None, + last_publish_status=None, + last_publish_error=None, + created_at=_now_iso(), + ) + backend.insert_deploy_target(target) + return _target_out(target) + + @app.post("/deploy_targets/{target_id}/publish", + responses={501: {"description": "engine capability missing"}}) + async def publish_to_deploy_target(target_id: str, body: PublishIn) -> DeployTargetOut: + """Publishing is a single row change; the push outcome is recorded + beside the selection and never rolls it back (spec 0011).""" + target = backend.get_deploy_target(target_id) + if target is None: + raise HTTPException(404, f"deploy target not found: {target_id}") + model = backend.get_model(body.model_id) + if model is None: + raise HTTPException(404, f"model not found: {body.model_id}") + + kind = EngineKind(model.engine) + engine = engines[kind] + try: + native_target = _native_target(engine) + bundle = engine.package(model.artifact_path, native_target) + except NotSupportedError as error: + return _capability_missing(kind, "package", error) + + status, error = _dispatch_package( + target_kind=target.kind, + config=json.loads(target.config_json), + bundle=bundle, + engine=model.engine, + phrase=backend.get_phrase(model.phrase_id), + version=model.version, + file_ext=_PACKAGE_EXTENSIONS.get(native_target, ".bin"), + ) + at = _now_iso() + backend.record_publish(target.id, model.id, status, error, at) + return _target_out(backend.get_deploy_target(target.id)) # type: ignore[arg-type] + static_dir = Path(__file__).parent / "static" if static_dir.exists(): app.mount("/ui", StaticFiles(directory=str(static_dir), html=True), name="ui") @@ -558,6 +989,29 @@ async def root() -> RedirectResponse: return app +def _model_out(model: Model) -> ModelOut: + try: + metrics = json.loads(model.metrics_json) + except json.JSONDecodeError: + LOG.warning( + "model has unparsable metrics_json; surfacing empty id=%s", model.id + ) + metrics = {} + return ModelOut( + id=model.id, + phrase_id=model.phrase_id, + engine=model.engine, + version=model.version, + engine_phrase_key=model.engine_phrase_key, + source=model.source, + filesystem_path=model.filesystem_path, + artifact_path=model.artifact_path, + metrics=metrics if isinstance(metrics, dict) else {}, + notes=model.notes, + created_at=model.created_at, + ) + + def _clip_out(backend: Backend, clip: Clip) -> ClipOut: return ClipOut( id=clip.id, @@ -573,8 +1027,110 @@ def _clip_out(backend: Backend, clip: Clip) -> ClipOut: ) +def _native_target(engine: WakeWordEngine) -> str: + targets = getattr(engine, "package_targets", ()) + if not targets: + raise NotSupportedError( + f"{engine.kind.value}: no package target declared" + ) + return targets[0] + + +_PACKAGE_EXTENSIONS = { + "tflite_micro": ".tflite", + "onnx": ".onnx", +} + + +def _dispatch_package( + *, + target_kind: str, + config: dict[str, object], + bundle: bytes, + engine: str, + phrase: Phrase | None, + version: str, + file_ext: str, +) -> tuple[str, str | None]: + """Push packaged bytes through one of the three transports. + + Returns `(status, error)` — 'ok' or 'failed'. A failed push is + recorded on the row, never rolled back (spec 0011, at-least-once). + """ + phrase_name = phrase.name if phrase else "unknown" + slug = re.sub(r"[^a-z0-9]+", "-", phrase_name.lower()).strip("-") or "model" + + if target_kind == "file": + directory = Path(str(config.get("directory") or "")) + try: + directory.mkdir(parents=True, exist_ok=True) + (directory / f"{slug}-{version}{file_ext}").write_bytes(bundle) + except OSError as err: + return "failed", f"could not write package: {err}" + return "ok", None + + if target_kind == "http_push": + url = str(config.get("url") or "") + try: + response = httpx.post( + url, + content=bundle, + headers={ + "Content-Type": "application/octet-stream", + "X-Excita-Engine": engine, + "X-Excita-Phrase": phrase_name, + "X-Excita-Version": version, + }, + timeout=10.0, + ) + except httpx.HTTPError as err: + return "failed", f"push to {url} failed: {err}" + if response.is_success: + return "ok", None + return "failed", f"push to {url} returned HTTP {response.status_code}" + + # linked_service_config: the linked service pulls its wake-word + # configuration from Excita's deploy-target view (over the conduit-link + # channel), so the current_model_id row change IS the publish. + return "ok", None + + +def _target_out(target: DeployTarget) -> DeployTargetOut: + try: + config = json.loads(target.config_json) + except json.JSONDecodeError: + config = {} + return DeployTargetOut( + id=target.id, + kind=target.kind, + config=config if isinstance(config, dict) else {}, + current_model_id=target.current_model_id, + last_publish_at=target.last_publish_at, + last_publish_status=target.last_publish_status, + last_publish_error=target.last_publish_error, + created_at=target.created_at, + ) + + if __name__ == "__main__": + import signal + import uvicorn + application = create_app() + + def _on_sighup(_signum: int, _frame: object) -> None: + """Re-scan the model import mount without a restart (#213).""" + scanner = getattr(application.state, "model_importer", None) + if scanner is None: + return + result = scanner.scan() + LOG.info( + "SIGHUP model import scan imported=%d updated=%d removed=%d errors=%d", + len(result.imported_ids), len(result.updated_ids), + len(result.removed_ids), result.errors, + ) + + signal.signal(signal.SIGHUP, _on_sighup) logging.basicConfig(level=logging.INFO) - uvicorn.run(create_app(), host="0.0.0.0", port=DEFAULT_PORT) + uvicorn.run(application, host="0.0.0.0", port=DEFAULT_PORT) diff --git a/services/excita/backend.py b/services/excita/backend.py index b4c5b37..604dad5 100644 --- a/services/excita/backend.py +++ b/services/excita/backend.py @@ -23,6 +23,52 @@ class Phrase: name: str display_label: str language: str + notes: str | None = None + deleted_at: str | None = None + + +@dataclass(frozen=True) +class Model: + """A trained wake-word model registered against a phrase. + + `engine_phrase_key` is the engine-native tag the adapter reads from + the model's own output (ADR-0022) — e.g. openWakeWord's ONNX output + key or nanoWakeWord's artifact stem. Two models bound to the same + phrase may carry different keys; they are different files. + """ + + id: str + phrase_id: str + engine: str + version: str + engine_phrase_key: str | None + source: str # 'upload' | 'filesystem' + filesystem_path: str | None # relative to the scanner mount root + artifact_path: str + metrics_json: str # {"envelope": {...}, "raw": {...}} + notes: str | None + file_mtime: str | None + file_size: int + created_at: str + deleted_at: str | None + + +@dataclass(frozen=True) +class DeployTarget: + """Where a packaged model gets published (spec 0011 §Configure & publish). + + Publishing is a single row change (`current_model_id`) plus a push; + a failed push never rolls back the row (spec 0011, at-least-once). + """ + + id: str + kind: str # 'file' | 'http_push' | 'linked_service_config' + config_json: str + current_model_id: str | None + last_publish_at: str | None + last_publish_status: str | None + last_publish_error: str | None + created_at: str @dataclass(frozen=True) @@ -69,6 +115,44 @@ def insert_clip(self, clip: Clip) -> None: ... def get_label(self, clip_id: str, labeller: str) -> Label | None: ... def upsert_label(self, label: Label) -> None: ... + def get_phrase_by_name(self, name: str) -> Phrase | None: ... + + def list_models( + self, + phrase_id: str | None = None, + source: str | None = None, + include_deleted: bool = False, + ) -> list[Model]: ... + def get_model( + self, model_id: str, include_deleted: bool = False + ) -> Model | None: ... + def get_filesystem_model( + self, filesystem_path: str, version: str + ) -> Model | None: ... + def get_model_by_version( + self, phrase_id: str, engine: str, version: str + ) -> Model | None: ... + def promote_upload_model( + self, model_id: str, filesystem_path: str, mtime: str, size: int + ) -> None: ... + def active_filesystem_paths(self) -> set[str]: ... + def insert_model(self, model: Model) -> None: ... + def touch_model(self, model_id: str, mtime: str, size: int) -> None: ... + def resurrect_model(self, model_id: str, mtime: str, size: int) -> None: ... + def soft_delete_model(self, model_id: str) -> None: ... + + def list_deploy_targets(self) -> list[DeployTarget]: ... + def get_deploy_target(self, target_id: str) -> DeployTarget | None: ... + def insert_deploy_target(self, target: DeployTarget) -> None: ... + def record_publish( + self, + target_id: str, + current_model_id: str, + status: str, + error: str | None, + at: str, + ) -> None: ... + _SCHEMA = """ CREATE TABLE IF NOT EXISTS phrases ( @@ -104,8 +188,64 @@ def upsert_label(self, label: Label) -> None: ... PRIMARY KEY (clip_id, labeller) ); CREATE INDEX IF NOT EXISTS idx_labels_verdict ON labels(verdict); + +-- A trained model. Phrases are engine-agnostic (ADR-0022); models carry +-- the engine. UNIQUE(phrase_id, engine, version): the same engine can't +-- have two v3s of the same phrase. +CREATE TABLE IF NOT EXISTS models ( + id TEXT PRIMARY KEY, + phrase_id TEXT NOT NULL REFERENCES phrases(id), + engine TEXT NOT NULL, + version TEXT NOT NULL, + engine_phrase_key TEXT, + source TEXT NOT NULL CHECK (source IN ('upload', 'filesystem')), + filesystem_path TEXT, + artifact_path TEXT NOT NULL, + metrics_json TEXT NOT NULL DEFAULT '{}', + notes TEXT, + file_mtime TEXT, + file_size INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + UNIQUE (phrase_id, engine, version) +); +CREATE INDEX IF NOT EXISTS idx_models_phrase ON models(phrase_id); +CREATE INDEX IF NOT EXISTS idx_models_filesystem ON models(filesystem_path); + +-- Where packaged models get published. Three kinds (#213 §Deploy +-- targets); the packaged bytes flow through any of them per engine. +CREATE TABLE IF NOT EXISTS deploy_targets ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('file', 'http_push', 'linked_service_config')), + config_json TEXT NOT NULL, + current_model_id TEXT REFERENCES models(id), + last_publish_at TEXT, + last_publish_status TEXT, + last_publish_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); """ +# Additive column migrations for databases created by earlier builds — an +# existing openWakeWord install must keep working untouched (#213: +# adopting µWW/nWW is additive, not a migration). +_MIGRATIONS = { + "phrases": { + "notes": "TEXT", + "deleted_at": "TEXT", + }, +} + + +def _migrate(conn: sqlite3.Connection) -> None: + for table, columns in _MIGRATIONS.items(): + present = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")} + if not present: + continue # table doesn't exist yet; CREATE above handles it + for name, ddl in columns.items(): + if name not in present: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}") + class SqliteBackend: """Synchronous sqlite backend. @@ -121,6 +261,7 @@ def __init__(self, path: Path) -> None: self._conn = sqlite3.connect(str(path), check_same_thread=False) self._conn.execute("PRAGMA foreign_keys = ON") self._conn.executescript(_SCHEMA) + _migrate(self._conn) self._conn.commit() async def close(self) -> None: @@ -128,19 +269,28 @@ async def close(self) -> None: # --- phrases --- + _PHRASE_COLS = "id, name, display_label, language, notes, deleted_at" + def list_phrases(self) -> list[Phrase]: rows = self._conn.execute( - "SELECT id, name, display_label, language FROM phrases ORDER BY name" + f"SELECT {self._PHRASE_COLS} FROM phrases ORDER BY name" ).fetchall() return [Phrase(*r) for r in rows] def get_phrase(self, phrase_id: str) -> Phrase | None: row = self._conn.execute( - "SELECT id, name, display_label, language FROM phrases WHERE id = ?", + f"SELECT {self._PHRASE_COLS} FROM phrases WHERE id = ?", (phrase_id,), ).fetchone() return Phrase(*row) if row else None + def get_phrase_by_name(self, name: str) -> Phrase | None: + row = self._conn.execute( + f"SELECT {self._PHRASE_COLS} FROM phrases WHERE name = ?", + (name,), + ).fetchone() + return Phrase(*row) if row else None + def insert_phrase(self, phrase: Phrase) -> None: self._conn.execute( "INSERT INTO phrases (id, name, display_label, language) VALUES (?, ?, ?, ?)", @@ -235,5 +385,179 @@ def upsert_label(self, label: Label) -> None: self._conn.commit() + # --- models --- + + _MODEL_COLS = ( + "id, phrase_id, engine, version, engine_phrase_key, source, " + "filesystem_path, artifact_path, metrics_json, notes, " + "file_mtime, file_size, created_at, deleted_at" + ) + + def list_models( + self, + phrase_id: str | None = None, + source: str | None = None, + include_deleted: bool = False, + ) -> list[Model]: + where: list[str] = [] + args: list[object] = [] + if not include_deleted: + where.append("deleted_at IS NULL") + if phrase_id is not None: + where.append("phrase_id = ?") + args.append(phrase_id) + if source is not None: + where.append("source = ?") + args.append(source) + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + rows = self._conn.execute( + f"SELECT {self._MODEL_COLS} FROM models {where_sql} " + "ORDER BY created_at, version", + args, + ).fetchall() + return [Model(*r) for r in rows] + + def get_model(self, model_id: str, include_deleted: bool = False) -> Model | None: + row = self._conn.execute( + f"SELECT {self._MODEL_COLS} FROM models WHERE id = ?" + + ("" if include_deleted else " AND deleted_at IS NULL"), + (model_id,), + ).fetchone() + return Model(*row) if row else None + + def get_filesystem_model(self, filesystem_path: str, version: str) -> Model | None: + """Row for a scanner-managed file at a given sidecar version — any + deleted state, so a re-appearing file resurrects instead of + colliding with its own history.""" + row = self._conn.execute( + f"SELECT {self._MODEL_COLS} FROM models " + "WHERE source = 'filesystem' AND filesystem_path = ? AND version = ?", + (filesystem_path, version), + ).fetchone() + return Model(*row) if row else None + + def get_model_by_version( + self, phrase_id: str, engine: str, version: str + ) -> Model | None: + """Any-state lookup by the natural key — used when reconciling the + scanner mount against previously uploaded models.""" + row = self._conn.execute( + f"SELECT {self._MODEL_COLS} FROM models " + "WHERE phrase_id = ? AND engine = ? AND version = ?", + (phrase_id, engine, version), + ).fetchone() + return Model(*row) if row else None + + def promote_upload_model( + self, model_id: str, filesystem_path: str, mtime: str, size: int + ) -> None: + """An uploaded model copied into the scanner mount becomes a + filesystem-imported one in place — history preserved (#213 + user story 15).""" + self._conn.execute( + "UPDATE models SET source = 'filesystem', filesystem_path = ?, " + "file_mtime = ?, file_size = ?, deleted_at = NULL WHERE id = ?", + (filesystem_path, mtime, size, model_id), + ) + self._conn.commit() + + def active_filesystem_paths(self) -> set[str]: + return { + r[0] + for r in self._conn.execute( + "SELECT DISTINCT filesystem_path FROM models " + "WHERE source = 'filesystem' AND deleted_at IS NULL" + ) + } + + def insert_model(self, model: Model) -> None: + self._conn.execute( + f"INSERT INTO models ({self._MODEL_COLS}) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + model.id, model.phrase_id, model.engine, model.version, + model.engine_phrase_key, model.source, model.filesystem_path, + model.artifact_path, model.metrics_json, model.notes, + model.file_mtime, model.file_size, model.created_at, + model.deleted_at, + ), + ) + self._conn.commit() + + def touch_model(self, model_id: str, mtime: str, size: int) -> None: + """Update-in-place metadata refresh only — the version (and with it + the metrics/deploy history) is untouched (#213).""" + self._conn.execute( + "UPDATE models SET file_mtime = ?, file_size = ? WHERE id = ?", + (mtime, size, model_id), + ) + self._conn.commit() + + def resurrect_model(self, model_id: str, mtime: str, size: int) -> None: + self._conn.execute( + "UPDATE models SET deleted_at = NULL, file_mtime = ?, file_size = ? " + "WHERE id = ?", + (mtime, size, model_id), + ) + self._conn.commit() + + def soft_delete_model(self, model_id: str) -> None: + self._conn.execute( + "UPDATE models SET deleted_at = datetime('now') WHERE id = ?", + (model_id,), + ) + self._conn.commit() + + # --- deploy targets --- + + _TARGET_COLS = ( + "id, kind, config_json, current_model_id, last_publish_at, " + "last_publish_status, last_publish_error, created_at" + ) + + def list_deploy_targets(self) -> list[DeployTarget]: + rows = self._conn.execute( + f"SELECT {self._TARGET_COLS} FROM deploy_targets ORDER BY created_at" + ).fetchall() + return [DeployTarget(*r) for r in rows] + + def get_deploy_target(self, target_id: str) -> DeployTarget | None: + row = self._conn.execute( + f"SELECT {self._TARGET_COLS} FROM deploy_targets WHERE id = ?", + (target_id,), + ).fetchone() + return DeployTarget(*row) if row else None + + def insert_deploy_target(self, target: DeployTarget) -> None: + self._conn.execute( + f"INSERT INTO deploy_targets ({self._TARGET_COLS}) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + target.id, target.kind, target.config_json, + target.current_model_id, target.last_publish_at, + target.last_publish_status, target.last_publish_error, + target.created_at, + ), + ) + self._conn.commit() + + def record_publish( + self, + target_id: str, + current_model_id: str, + status: str, + error: str | None, + at: str, + ) -> None: + """The row change is the publish; push outcome is recorded beside it + and never rolls the selection back (spec 0011 §Configure & publish).""" + self._conn.execute( + "UPDATE deploy_targets SET current_model_id = ?, last_publish_at = ?, " + "last_publish_status = ?, last_publish_error = ? WHERE id = ?", + (current_model_id, at, status, error, target_id), + ) + self._conn.commit() + + def new_id() -> str: return uuid.uuid4().hex diff --git a/services/excita/engines/__init__.py b/services/excita/engines/__init__.py index 7ff5842..6848301 100644 --- a/services/excita/engines/__init__.py +++ b/services/excita/engines/__init__.py @@ -1,17 +1,33 @@ -"""Engine adapter registry (see spec 0011 §Engine abstraction). +"""Engine adapter registry (see spec 0011 §Engine abstraction, #213). -Only the null adapter ships in the scaffold. Real openWakeWord / -microWakeWord / Porcupine adapters are follow-ons. +Each engine is one file declaring, honestly, which of the four operations +(`load`/`feed`, `score`, `train`, `package`) it implements — ADR-0020. +Asking for an undeclared capability raises `NotSupportedError`, which the +HTTP surface translates into a structured 501 — ADR-0023. """ -from .base import Detector, EngineKind, NotSupportedError, NullEngine, WakeWordEngine +from .base import ( + CAPABILITIES, + Detector, + EngineKind, + NotSupportedError, + NullEngine, + WakeWordEngine, + capability_view, +) +from .microwakeword import MicroWakeWordEngine +from .nanowakeword import NanoWakeWordEngine from .openwakeword import OpenWakeWordEngine __all__ = [ + "CAPABILITIES", "Detector", "EngineKind", + "MicroWakeWordEngine", + "NanoWakeWordEngine", "NotSupportedError", "NullEngine", "OpenWakeWordEngine", "WakeWordEngine", + "capability_view", ] diff --git a/services/excita/engines/base.py b/services/excita/engines/base.py index c9b6bd4..c92ceb7 100644 --- a/services/excita/engines/base.py +++ b/services/excita/engines/base.py @@ -9,9 +9,16 @@ class EngineKind(str, Enum): OPENWAKEWORD = "openwakeword" MICROWAKEWORD = "microwakeword" + NANOWAKEWORD = "nanowakeword" PORCUPINE = "porcupine" +# The five cells an adapter can honestly advertise (#213 §Capability +# contract). `feed` is only reachable through a loaded `Detector`, so it is +# declared alongside `load` — no adapter supports one without the other. +CAPABILITIES = ("load", "feed", "score", "train", "package") + + class NotSupportedError(RuntimeError): """Raised by adapters for operations they cannot perform. @@ -36,6 +43,11 @@ def reset(self) -> None: ... class WakeWordEngine(Protocol): kind: EngineKind + # Capability advertisement (ADR-0020). Declared next to the methods that + # would raise `NotSupportedError` — the declaration and the behaviour live + # in the same file so they can't drift unnoticed. + capabilities: frozenset[str] + package_targets: tuple[str, ...] def load(self, model_ref: str, phrase_id: str) -> Detector: ... def score(self, audio: bytes, model_ref: str) -> list[float]: ... @@ -43,6 +55,16 @@ def train(self, dataset_snapshot_id: str, base: str | None) -> str: ... def package(self, model_ref: str, target_kind: str) -> bytes: ... +def capability_view(engine: WakeWordEngine) -> dict[str, object]: + """Serialisation for `GET /engines` (#213 §Capability contract).""" + declared = getattr(engine, "capabilities", frozenset()) + return { + "kind": engine.kind.value, + "capabilities": {c: c in declared for c in CAPABILITIES}, + "package_targets": list(getattr(engine, "package_targets", ())), + } + + class NullEngine: """Placeholder that answers the API surface without doing any work. @@ -51,6 +73,10 @@ class NullEngine: exercised end-to-end before a real adapter lands. """ + kind: EngineKind + capabilities = frozenset[str]() + package_targets: tuple[str, ...] = () + def __init__(self, kind: EngineKind) -> None: self.kind = kind diff --git a/services/excita/engines/microwakeword.py b/services/excita/engines/microwakeword.py new file mode 100644 index 0000000..87a8bdd --- /dev/null +++ b/services/excita/engines/microwakeword.py @@ -0,0 +1,108 @@ +"""microWakeWord engine adapter. + +microWakeWord exists to run on the ESP32 — its whole point is streaming +TFLite-Micro inference inside an MCU's memory budget (ADR-0020). Excita's +adapter is therefore deliberately partial: + +- `score` runs the µWW TFLite model over a stored clip on CPU via + `tflite-runtime` and returns the per-hop score curve, so a new model can + be regression-checked against stored clips before anything is flashed. +- `package(target_kind="tflite_micro")` returns the flashable blob the + ESPHome device consumes. +- `load`/`feed` raise `NotSupportedError`: arming a live µWW detector on + the Excita host was never going to work, and saying so plainly beats a + silently useless binding. +- `train` raises `NotSupportedError` pointing at `EXCITA_TRAIN_WORKER_URL` + — µWW's TF pipeline does not belong in the process serving HTTP. +""" + +from __future__ import annotations + +import wave +from io import BytesIO +from pathlib import Path + +import numpy as np + +from .base import Detector, EngineKind, NotSupportedError + +try: # pragma: no cover - import guard, exercised only on wheels-less hosts + from tflite_runtime.interpreter import Interpreter as _TfLiteInterpreter +except ImportError: # noqa: F401 - scored below via `_tflite_available` + _TfLiteInterpreter = None # type: ignore[assignment,misc] + +SAMPLE_RATE = 16000 + + +def _tflite_available() -> bool: + return _TfLiteInterpreter is not None + + +class MicroWakeWordEngine: + """microWakeWord engine: offline scoring + packaging, nothing live.""" + + kind = EngineKind.MICROWAKEWORD + capabilities = frozenset({"score", "package"}) + package_targets = ("tflite_micro",) + + def load(self, model_ref: str, phrase_id: str) -> Detector: + raise NotSupportedError( + "microwakeword does not run live host-side detection in Excita; " + "detection happens on the ESP32. Use score() against stored clips " + "or package() for the device." + ) + + def score(self, audio: bytes, model_ref: str) -> list[float]: + """Per-hop scores across a full 16 kHz mono PCM WAV.""" + if not Path(model_ref).exists(): + raise FileNotFoundError(f"microwakeword model not found: {model_ref}") + if _TfLiteInterpreter is None: + raise RuntimeError( + "tflite-runtime is not installed; microWakeWord scoring " + "requires it (pip install tflite-runtime)" + ) + + with wave.open(BytesIO(audio)) as wav: + if wav.getnchannels() != 1 or wav.getframerate() != SAMPLE_RATE: + raise ValueError( + "microwakeword expects 16 kHz mono; got " + f"{wav.getframerate()} Hz {wav.getnchannels()}ch" + ) + pcm = wav.readframes(wav.getnframes()) + + interpreter = _TfLiteInterpreter(model_path=model_ref) + interpreter.allocate_tensors() + input_detail = interpreter.get_input_details()[0] + output_detail = interpreter.get_output_details()[0] + # µWW models consume int16 audio windows; the window length is a + # property of the trained model, so read it off the artifact rather + # than hardcoding it. + window = int(input_detail["shape"][-1]) + + samples = np.frombuffer(pcm, dtype=np.int16) + curve: list[float] = [] + for start in range(0, len(samples) - window + 1, window): + hop = samples[start : start + window] + interpreter.set_tensor(input_detail["index"], hop.reshape(1, -1)) + interpreter.invoke() + out = interpreter.get_tensor(output_detail["index"]) + curve.append(float(out.reshape(-1)[-1])) + return curve + + def train(self, dataset_snapshot_id: str, base: str | None) -> str: + raise NotSupportedError( + "microwakeword training does not run in-process; configure " + "EXCITA_TRAIN_WORKER_URL to route training to an external worker." + ) + + def package(self, model_ref: str, target_kind: str) -> bytes: + if target_kind != "tflite_micro": + raise NotSupportedError( + f"microwakeword: package target '{target_kind}' not supported; " + "the only native target is 'tflite_micro'" + ) + if not Path(model_ref).exists(): + raise FileNotFoundError(f"microwakeword model not found: {model_ref}") + # The trained .tflite IS the flashable blob — ESPHome's microwakeword + # component consumes it verbatim. + return Path(model_ref).read_bytes() diff --git a/services/excita/engines/nanowakeword.py b/services/excita/engines/nanowakeword.py new file mode 100644 index 0000000..0a90c56 --- /dev/null +++ b/services/excita/engines/nanowakeword.py @@ -0,0 +1,151 @@ +"""nanoWakeWord engine adapter. + +Directly wraps the `nanowakeword` PyPI package's `NanoInterpreter` +(ADR-0020). Follows the openWakeWord adapter shape: 16 kHz mono int16, +1280-sample chunks, a residual buffer so sources that don't send exact +80 ms frames still stream cleanly, and `reset()` clearing both the +residual and the interpreter's hidden state. + +The engine-native phrase key is the artifact stem — nanoWakeWord names +its output channel after the model file (`hey_jarvis.onnx` scores under +`"hey_jarvis"`), which is what lands in the model row's +`engine_phrase_key`. + +The gate-on-MCU + remote-verifier cascade mode is a separate spec and is +not wired here; models load as single verifiers. +""" + +from __future__ import annotations + +import wave +from io import BytesIO +from pathlib import Path + +import numpy as np +from nanowakeword.interpreter import NanoInterpreter + +from .base import Detector, EngineKind, NotSupportedError + +SAMPLE_RATE = 16000 +CHUNK_SAMPLES = 1280 +DEFAULT_THRESHOLD = 0.5 + + +def phrase_key_of(model_ref: str) -> str: + """nanoWakeWord's native output key: the artifact file stem.""" + return Path(model_ref).stem + + +class _Detector: + """Live-audio handle around one `NanoInterpreter`.""" + + kind = EngineKind.NANOWAKEWORD + sample_rate = SAMPLE_RATE + + def __init__( + self, + *, + phrase_id: str, + interpreter: NanoInterpreter, + threshold: float, + phrase_key: str, + ) -> None: + self.phrase_id = phrase_id + self._interpreter = interpreter + self._threshold = threshold + self._phrase_key = phrase_key + # Trailing PCM that didn't reach a full chunk (see openWakeWord + # adapter — same contract, same reason). + self._residual = np.zeros(0, dtype=np.int16) + + def feed(self, pcm_frame: bytes) -> tuple[float, bool] | None: + if not pcm_frame: + return None + incoming = np.frombuffer(pcm_frame, dtype=np.int16) + buffered = np.concatenate([self._residual, incoming]) + n_chunks = len(buffered) // CHUNK_SAMPLES + if n_chunks == 0: + self._residual = buffered + return None + + max_score = 0.0 + for i in range(n_chunks): + start = i * CHUNK_SAMPLES + chunk = buffered[start : start + CHUNK_SAMPLES] + result = self._interpreter.predict(chunk) + score = float(result.get(self._phrase_key, result.score)) + if score > max_score: + max_score = score + self._residual = buffered[n_chunks * CHUNK_SAMPLES :] + return max_score, max_score >= self._threshold + + def reset(self) -> None: + self._residual = np.zeros(0, dtype=np.int16) + self._interpreter.reset() + + +class NanoWakeWordEngine: + """nanoWakeWord engine: live host-side detection + offline scoring.""" + + kind = EngineKind.NANOWAKEWORD + capabilities = frozenset({"load", "feed", "score", "package"}) + package_targets = ("onnx",) + + def __init__(self, *, default_threshold: float = DEFAULT_THRESHOLD) -> None: + self._default_threshold = default_threshold + + def load( + self, + model_ref: str, + phrase_id: str, + threshold: float | None = None, + ) -> Detector: + model_path = Path(model_ref) + if not model_path.exists(): + raise FileNotFoundError(f"nanowakeword model not found: {model_ref}") + interpreter = NanoInterpreter.load_model(str(model_path)) + return _Detector( + phrase_id=phrase_id, + interpreter=interpreter, + threshold=self._default_threshold if threshold is None else threshold, + phrase_key=phrase_key_of(model_ref), + ) + + def score(self, audio: bytes, model_ref: str) -> list[float]: + """Per-chunk scores across a full PCM WAV. + + A fresh interpreter per call keeps the debug view deterministic — + live streaming state must not leak into an offline re-score. + """ + detector = self.load(model_ref, phrase_id="_debug_") + with wave.open(BytesIO(audio)) as wav: + if wav.getnchannels() != 1 or wav.getframerate() != SAMPLE_RATE: + raise ValueError( + "nanowakeword expects 16 kHz mono; got " + f"{wav.getframerate()} Hz {wav.getnchannels()}ch" + ) + pcm = wav.readframes(wav.getnframes()) + samples = np.frombuffer(pcm, dtype=np.int16) + curve: list[float] = [] + for i in range(0, len(samples) - CHUNK_SAMPLES + 1, CHUNK_SAMPLES): + result = detector._interpreter.predict(samples[i : i + CHUNK_SAMPLES]) + curve.append(float(result.get(detector._phrase_key, result.score))) + return curve + + def train(self, dataset_snapshot_id: str, base: str | None) -> str: + raise NotSupportedError( + "nanowakeword training does not run in-process; configure " + "EXCITA_TRAIN_WORKER_URL to route training to an external worker." + ) + + def package(self, model_ref: str, target_kind: str) -> bytes: + if target_kind != "onnx": + raise NotSupportedError( + f"nanowakeword: package target '{target_kind}' not supported; " + "the only native target is 'onnx'" + ) + if not Path(model_ref).exists(): + raise FileNotFoundError(f"nanowakeword model not found: {model_ref}") + # The trained ONNX verifier IS the package for host-side runtimes; + # cross-engine conversion is out of contract (#213). + return Path(model_ref).read_bytes() diff --git a/services/excita/engines/openwakeword.py b/services/excita/engines/openwakeword.py index ba19763..d63ba17 100644 --- a/services/excita/engines/openwakeword.py +++ b/services/excita/engines/openwakeword.py @@ -94,6 +94,8 @@ class OpenWakeWordEngine: """ kind = EngineKind.OPENWAKEWORD + capabilities = frozenset({"load", "feed", "score", "package"}) + package_targets = ("onnx",) def __init__( self, @@ -166,9 +168,14 @@ def train(self, dataset_snapshot_id: str, base: str | None) -> str: raise NotSupportedError("openwakeword: train not implemented (see spec 0011)") def package(self, model_ref: str, target_kind: str) -> bytes: - # The trained model IS the package for openWakeWord's own runtime - # (single ONNX classifier). For microWakeWord/Porcupine targets a - # cross-engine conversion would live here — none exists yet. - raise NotSupportedError( - f"openwakeword: package for '{target_kind}' not implemented" - ) + if target_kind != "onnx": + raise NotSupportedError( + f"openwakeword: package target '{target_kind}' not supported; " + "the only native target is 'onnx'" + ) + if not Path(model_ref).exists(): + raise FileNotFoundError(f"openwakeword model not found: {model_ref}") + # The trained ONNX classifier IS the package for openWakeWord's own + # runtime. For microWakeWord/Porcupine targets a cross-engine + # conversion would live here — none exists (#213 §Out of scope). + return Path(model_ref).read_bytes() diff --git a/services/excita/model_import.py b/services/excita/model_import.py new file mode 100644 index 0000000..1f5fd24 --- /dev/null +++ b/services/excita/model_import.py @@ -0,0 +1,254 @@ +"""Filesystem model-import scanner (#213 §Model import). + +Watches `EXCITA_MODEL_IMPORT_DIR` — the bind-mounted directory operators +drop pre-trained models into. Each artifact is described by an +`.excita.json` sidecar next to it: + + {"engine": "microwakeword", "phrase_name": "hey jarvis", + "version": "v3", "engine_phrase_key": "hey_jarvis_v3"} + +Reconciliation rules (the volume is the source of truth, ADR-0021): + +- new sidecar → insert a `source=filesystem` model row +- file gone → soft-delete the row (`deleted_at`) +- file present, sidecar `version` unchanged → update-in-place metadata + refresh only; never a new row (a `cp -p` round trip must not mint a + spurious model) +- sidecar `version` changed → insert a new row; the previous row keeps + its metrics and deploy history +- malformed or missing sidecar → log and skip; nothing partially registers + +Phrase names resolve through ADR-0022: a sidecar naming an unknown phrase +creates the phrase row — phrases are shared across engines. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from .backend import Backend, Model, Phrase, new_id +from .engines import EngineKind + +LOG = logging.getLogger("excita.model_import") + +SIDECAR_SUFFIX = ".excita.json" + + +@dataclass +class ScanResult: + imported_ids: list[str] = field(default_factory=list) + updated_ids: list[str] = field(default_factory=list) + removed_ids: list[str] = field(default_factory=list) + errors: int = 0 + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class ModelImporter: + """Reconciles the import mount against `model` rows.""" + + def __init__(self, backend: Backend, import_dir: Path) -> None: + self._backend = backend + self._import_dir = Path(import_dir) + + def scan(self) -> ScanResult: + result = ScanResult() + seen_paths: set[str] = set() + + if not self._import_dir.exists(): + LOG.warning( + "model import dir does not exist; nothing to scan path=%s", + self._import_dir, + ) + return self._reap(result, seen_paths) + + for sidecar_path in sorted(self._import_dir.rglob(f"*{SIDECAR_SUFFIX}")): + parsed = self._parse_sidecar(sidecar_path) + if parsed is None: + result.errors += 1 + continue + artifact_path, meta = parsed + rel_path = artifact_path.relative_to(self._import_dir).as_posix() + seen_paths.add(rel_path) + stat = artifact_path.stat() + mtime = datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat( + timespec="seconds" + ) + + existing = self._backend.get_filesystem_model(rel_path, meta["version"]) + if existing is None: + # A sidecar whose (phrase, engine, version) matches a + # previously *uploaded* model is that model promoted: the + # operator copied it into the mount. Convert in place so + # metrics and deploy history survive (user story 15). + phrase = self._resolve_phrase(meta["phrase_name"]) + promoted = False + if phrase is not None: + candidate = self._backend.get_model_by_version( + phrase.id, meta["engine"], meta["version"] + ) + if candidate is not None and candidate.source == "upload": + self._backend.promote_upload_model( + candidate.id, rel_path, mtime, stat.st_size + ) + result.imported_ids.append(candidate.id) + promoted = True + if not promoted: + model_id = self._insert_model( + artifact_path, rel_path, meta, mtime, stat.st_size + ) + if model_id is not None: + result.imported_ids.append(model_id) + else: + result.errors += 1 + elif existing.deleted_at is not None: + # The file came back (restore, re-mount). Resurrect rather + # than collide with its own UNIQUE(phrase, engine, version). + self._backend.resurrect_model(existing.id, mtime, stat.st_size) + result.imported_ids.append(existing.id) + else: + before = (existing.file_mtime, existing.file_size) + after = (mtime, stat.st_size) + if before != after: + self._backend.touch_model(existing.id, mtime, stat.st_size) + result.updated_ids.append(existing.id) + + return self._reap(result, seen_paths) + + # --- internals --- + + def _parse_sidecar(self, sidecar_path: Path) -> tuple[Path, dict] | None: + """Returns `(artifact_path, normalized_meta)`, or None on any problem. + + Malformed JSON, missing required fields, unknown engines, and + artifacts without bytes all land here — logged and skipped, never + partially registered. + """ + try: + raw = json.loads(sidecar_path.read_text()) + except (OSError, json.JSONDecodeError) as error: + LOG.error("unreadable model sidecar path=%s error=%s", sidecar_path, error) + return None + if not isinstance(raw, dict): + LOG.error("model sidecar is not an object path=%s", sidecar_path) + return None + + engine = raw.get("engine") + phrase_name = raw.get("phrase_name") + version = raw.get("version") + if ( + not isinstance(engine, str) + or engine not in {k.value for k in EngineKind} + or not isinstance(phrase_name, str) + or not phrase_name.strip() + or not isinstance(version, str) + or not version.strip() + ): + LOG.error( + "model sidecar missing/invalid required fields " + "(engine, phrase_name, version) path=%s", + sidecar_path, + ) + return None + + artifact_path = sidecar_path.with_name( + sidecar_path.name[: -len(SIDECAR_SUFFIX)] + ) + if not artifact_path.is_file(): + LOG.error( + "sidecar has no artifact next to it sidecar=%s artifact=%s", + sidecar_path, + artifact_path, + ) + return None + + metrics = raw.get("metrics_json") or {} + notes = raw.get("notes") + return artifact_path, { + "engine": engine, + "phrase_name": phrase_name.strip(), + "version": version.strip(), + "engine_phrase_key": raw.get("engine_phrase_key"), + "metrics": metrics if isinstance(metrics, dict) else {}, + "notes": notes if isinstance(notes, str) else None, + } + + def _resolve_phrase(self, name: str) -> Phrase | None: + phrase = self._backend.get_phrase_by_name(name) + if phrase is not None: + return phrase + phrase = Phrase(id=new_id(), name=name, display_label=name, language="en") + try: + self._backend.insert_phrase(phrase) + except Exception as error: # noqa: BLE001 — raced creation is fine, re-read below + LOG.warning("phrase insert raced during import name=%s error=%s", name, error) + return self._backend.get_phrase_by_name(name) + return phrase + + def _insert_model( + self, + artifact_path: Path, + rel_path: str, + meta: dict, + mtime: str, + size: int, + ) -> str | None: + phrase = self._resolve_phrase(meta["phrase_name"]) + if phrase is None: + LOG.error("could not resolve phrase during scan name=%s", meta["phrase_name"]) + return None + engine_phrase_key = meta["engine_phrase_key"] + model = Model( + id=new_id(), + phrase_id=phrase.id, + engine=meta["engine"], + version=meta["version"], + engine_phrase_key=( + engine_phrase_key + if isinstance(engine_phrase_key, str) and engine_phrase_key + else artifact_path.stem + ), + source="filesystem", + filesystem_path=rel_path, + artifact_path=str(artifact_path), + metrics_json=json.dumps(meta["metrics"]), + notes=meta["notes"], + file_mtime=mtime, + file_size=size, + created_at=_now_iso(), + deleted_at=None, + ) + try: + self._backend.insert_model(model) + except Exception as error: # noqa: BLE001 — UNIQUE collisions surface here + LOG.error( + "failed to register scanned model path=%s version=%s error=%s", + artifact_path, + meta["version"], + error, + ) + return None + LOG.info( + "registered filesystem model engine=%s phrase=%s version=%s path=%s", + meta["engine"], meta["phrase_name"], meta["version"], rel_path, + ) + return model.id + + def _reap(self, result: ScanResult, seen_paths: set[str]) -> ScanResult: + """Soft-delete rows whose files vanished from the mount.""" + for stale_path in sorted(self._backend.active_filesystem_paths() - seen_paths): + for model in self._backend.list_models(source="filesystem"): + if model.filesystem_path == stale_path and model.deleted_at is None: + self._backend.soft_delete_model(model.id) + result.removed_ids.append(model.id) + LOG.info( + "soft-deleted filesystem model with missing file path=%s", + stale_path, + ) + return result diff --git a/services/excita/requirements.txt b/services/excita/requirements.txt index cc01f55..437fc30 100644 --- a/services/excita/requirements.txt +++ b/services/excita/requirements.txt @@ -10,3 +10,11 @@ python-multipart>=0.0.9 openwakeword>=0.6.0 onnxruntime>=1.16 numpy>=1.24 +# microWakeWord offline scoring (spec 0013). Hard dep in the image — one +# image, one behavior, no per-engine variant. Marker only skips it on +# dev hosts where tflite-runtime publishes no wheels (macOS); there the +# adapter raises honestly instead of scoring. +tflite-runtime>=2.14; sys_platform == "linux" +# nanoWakeWord live detection + scoring (spec 0013). Pure Python on top of +# onnxruntime; its preprocessing ONNX files download on first model load. +nanowakeword>=0.1 diff --git a/services/excita/test_app.py b/services/excita/test_app.py index 9a1fd36..497262b 100644 --- a/services/excita/test_app.py +++ b/services/excita/test_app.py @@ -66,6 +66,7 @@ def config(tmp_path: Path) -> Config: base_url="http://localhost:8084", wake_models_dir=WAKE_MODELS_DIR if _wake_models_available() else None, pre_roll_ms=2000, + model_import_dir=tmp_path / "import", ) @@ -231,8 +232,8 @@ def test_audio_frame_no_bindings_is_a_no_op(client: TestClient) -> None: def test_arm_detector_without_model_returns_501(client: TestClient) -> None: - """No wake_models_dir configured (or fetch script not run) → null engine - still refuses honestly with an engine-named message.""" + """An engine whose adapter hasn't landed yet (porcupine's NullEngine + slot) refuses honestly with a structured capability-gap body.""" phrase_id = _create_phrase(client) resp = client.post( "/detectors", @@ -240,11 +241,15 @@ def test_arm_detector_without_model_returns_501(client: TestClient) -> None: "phrase_id": phrase_id, "model_ref": "/nonexistent/hey_jarvis.onnx", "source_device": "kitchen", - "engine": "microwakeword", # slot deliberately still NullEngine + "engine": "porcupine", }, ) assert resp.status_code == 501 - assert "microwakeword" in resp.json()["detail"] + body = resp.json() + assert body["code"] == "engine_capability_missing" + assert body["engine"] == "porcupine" + assert body["capability"] == "load" + assert body["message"] def test_arm_detector_missing_phrase_404s(client: TestClient) -> None: @@ -378,6 +383,620 @@ def test_bindings_are_source_scoped(client: TestClient) -> None: assert resp.json()["fires"] == 0 +# --- engine capability matrix (#213 / ADR-0020) --- + + +def test_engines_lists_full_roster_with_capabilities(client: TestClient) -> None: + resp = client.get("/engines") + assert resp.status_code == 200 + engines = {e["kind"]: e for e in resp.json()} + assert set(engines) == {"openwakeword", "microwakeword", "nanowakeword", "porcupine"} + + def caps(kind: str) -> dict: + return engines[kind]["capabilities"] + + assert caps("openwakeword") == { + "load": True, "feed": True, "score": True, "train": False, "package": True, + } + # microWakeWord detects on the ESP32 — no host-side live detection. + assert caps("microwakeword") == { + "load": False, "feed": False, "score": True, "train": False, "package": True, + } + assert caps("nanowakeword") == { + "load": True, "feed": True, "score": True, "train": False, "package": True, + } + # Porcupine's slot is still a NullEngine until its adapter lands. + assert all(v is False for v in caps("porcupine").values()) + + assert engines["microwakeword"]["package_targets"] == ["tflite_micro"] + assert engines["nanowakeword"]["package_targets"] == ["onnx"] + assert engines["openwakeword"]["package_targets"] == ["onnx"] + assert engines["porcupine"]["package_targets"] == [] + + +def test_arm_microwakeword_returns_structured_501(client: TestClient) -> None: + """µWW has no host-side load/feed — ADR-0020/0023 structured 501 body.""" + phrase_id = _create_phrase(client) + resp = client.post( + "/detectors", + json={ + "phrase_id": phrase_id, + "model_ref": "/nonexistent/model.tflite", + "source_device": "kitchen", + "engine": "microwakeword", + }, + ) + assert resp.status_code == 501 + body = resp.json() + assert body["code"] == "engine_capability_missing" + assert body["engine"] == "microwakeword" + assert body["capability"] == "load" + assert "ESP32" in body["message"] + + +def test_train_nanowakeword_points_at_train_worker(client: TestClient) -> None: + phrase_id = _create_phrase(client) + resp = client.post( + "/train", + json={"phrase_id": phrase_id, "engine": "nanowakeword"}, + ) + assert resp.status_code == 501 + body = resp.json() + assert body["code"] == "engine_capability_missing" + assert body["engine"] == "nanowakeword" + assert body["capability"] == "train" + assert "EXCITA_TRAIN_WORKER_URL" in body["message"] + + +def _import_engine_placeholder(client: TestClient, engine: str) -> str: + """Register a minimal model row for an engine whose adapter is still + null, so capability-gap routes have something to dispatch to.""" + resp = _import_model( + client, + metadata=_import_metadata(engine=engine), + filename=f"placeholder-{engine}.tflite", + ) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +def test_score_null_engine_returns_structured_501(client: TestClient) -> None: + """Capability matrix cell (porcupine, score): structured 501, not a + hidden failure (ADR-0020/0023).""" + phrase_id = _create_phrase(client) + clip = _upload(client, phrase_id, _wav_bytes()) + model_id = _import_engine_placeholder(client, "porcupine") + resp = client.post( + "/debug/score", json={"clip_id": clip["id"], "model_id": model_id} + ) + assert resp.status_code == 501 + body = resp.json() + assert body == { + "code": "engine_capability_missing", + "engine": "porcupine", + "capability": "score", + "message": body["message"], + } + + +def test_package_null_engine_returns_structured_501( + client: TestClient, tmp_path: Path +) -> None: + """Capability matrix cell (porcupine, package).""" + model_id = _import_engine_placeholder(client, "porcupine") + target = _create_target(client, "file", {"directory": str(tmp_path / "out")}) + resp = client.post( + f"/deploy_targets/{target['id']}/publish", json={"model_id": model_id} + ) + assert resp.status_code == 501 + body = resp.json() + assert body["code"] == "engine_capability_missing" + assert body["capability"] == "package" + assert body["engine"] == "porcupine" + + +# --- model import surface (#213 §Model import) --- + + +def _import_metadata( + *, + engine: str = "microwakeword", + phrase_name: str = "hey jarvis", + version: str = "v1", + engine_phrase_key: str | None = None, + metrics: dict | None = None, + notes: str | None = None, +) -> str: + import json + + meta: dict = {"engine": engine, "phrase_name": phrase_name, "version": version} + if engine_phrase_key is not None: + meta["engine_phrase_key"] = engine_phrase_key + if metrics is not None: + meta["metrics_json"] = metrics + if notes is not None: + meta["notes"] = notes + return json.dumps(meta) + + +def _import_model( + client: TestClient, + *, + metadata: str, + artifact: bytes = b"fake-tflite-blob", + filename: str = "hey_jarvis_v1.tflite", +): + return client.post( + "/models/import", + data={"metadata": metadata}, + files={"file": (filename, artifact, "application/octet-stream")}, + ) + + +def test_import_creates_phrase_and_model(client: TestClient) -> None: + resp = _import_model( + client, metadata=_import_metadata(engine_phrase_key="hey_jarvis_v1") + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["engine"] == "microwakeword" + assert body["version"] == "v1" + assert body["source"] == "upload" + assert body["engine_phrase_key"] == "hey_jarvis_v1" + + # Unknown phrase_name was created on the fly. + phrases = client.get("/phrases").json() + assert [p["name"] for p in phrases] == ["hey jarvis"] + assert body["phrase_id"] == phrases[0]["id"] + + +def test_import_reuses_existing_phrase(client: TestClient) -> None: + phrase_id = _create_phrase(client, name="hey jarvis") + resp = _import_model(client, metadata=_import_metadata()) + assert resp.status_code == 201 + assert resp.json()["phrase_id"] == phrase_id + assert len(client.get("/phrases").json()) == 1 + + +def test_import_writes_sidecar_next_to_artifact(client: TestClient) -> None: + """Sidecar round-trip: copying the storage dir into the scanner mount + promotes an uploaded model without a rewrite step (user story 15).""" + import json + + resp = _import_model(client, metadata=_import_metadata(version="v2")) + body = resp.json() + artifact_path = Path(body["artifact_path"]) + sidecar_path = artifact_path.with_name(artifact_path.name + ".excita.json") + assert artifact_path.exists() + assert artifact_path.read_bytes() == b"fake-tflite-blob" + assert sidecar_path.exists() + + sidecar = json.loads(sidecar_path.read_text()) + assert sidecar["engine"] == "microwakeword" + assert sidecar["phrase_name"] == "hey jarvis" + assert sidecar["version"] == "v2" + + +def test_import_preserves_metrics_envelope_and_raw(client: TestClient) -> None: + """Normalized envelope for cross-engine ranking; raw keeps the engine's + native numbers intact (user stories 16–17).""" + metrics = { + "envelope": {"samples_val": 40, "samples_test": 20, "auc": 0.912}, + "raw": {"streaming_false_accepts_per_hour": 0.4}, + } + resp = _import_model(client, metadata=_import_metadata(metrics=metrics)) + assert resp.status_code == 201 + got = resp.json()["metrics"] + assert got["envelope"]["auc"] == 0.912 + assert got["raw"]["streaming_false_accepts_per_hour"] == 0.4 + + +def test_import_rejects_unknown_engine(client: TestClient) -> None: + resp = _import_model(client, metadata=_import_metadata(engine="picovoice")) + assert resp.status_code == 422 + + +def test_import_rejects_blank_version(client: TestClient) -> None: + resp = _import_model(client, metadata=_import_metadata(version=" ")) + assert resp.status_code == 422 + + +def test_import_same_phrase_engine_version_conflicts(client: TestClient) -> None: + """UNIQUE(phrase_id, engine, version) — the same engine can't have two + v3s of the same phrase (ADR-0022).""" + assert _import_model(client, metadata=_import_metadata()).status_code == 201 + resp = _import_model(client, metadata=_import_metadata(), filename="other.tflite") + assert resp.status_code == 409 + + +def test_same_phrase_across_engines_is_one_row_set(client: TestClient) -> None: + """Phrase is engine-agnostic (ADR-0022): one 'hey Jarvis' carries models + across engines.""" + oww = _import_model( + client, + metadata=_import_metadata(engine="openwakeword", version="v3"), + filename="hey_jarvis_oww.onnx", + ) + uww = _import_model(client, metadata=_import_metadata(version="v1")) + assert oww.status_code == 201 and uww.status_code == 201 + phrase_id = uww.json()["phrase_id"] + assert oww.json()["phrase_id"] == phrase_id + + detail = client.get(f"/phrases/{phrase_id}") + assert detail.status_code == 200 + assert {m["engine"] for m in detail.json()["models"]} == { + "openwakeword", "microwakeword", + } + listed = client.get("/models", params={"phrase_id": phrase_id}).json() + assert len(listed) == 2 + + +def test_delete_upload_model_soft_deletes(client: TestClient) -> None: + model_id = _import_model(client, metadata=_import_metadata()).json()["id"] + assert client.delete(f"/models/{model_id}").status_code == 204 + assert client.get("/models").json() == [] + assert client.get(f"/models/{model_id}").status_code == 404 + + +def test_delete_filesystem_imported_model_refused(client: TestClient) -> None: + """The volume is the source of truth — retiring means removing the file + on disk (ADR-0021).""" + import shutil + + # Import through the UI, then promote via copy into the scanner mount. + model = _import_model(client, metadata=_import_metadata()).json() + artifact = Path(model["artifact_path"]) + import_dir = client.app.state.config.model_import_dir + import_dir.mkdir(parents=True, exist_ok=True) + shutil.copy(artifact, import_dir / artifact.name) + shutil.copy( + artifact.with_name(artifact.name + ".excita.json"), + import_dir / (artifact.name + ".excita.json"), + ) + scan = client.post("/models/scan") + assert scan.status_code == 200 + fs_model_id = scan.json()["imported_ids"][0] + + resp = client.delete(f"/models/{fs_model_id}") + assert resp.status_code == 409 + assert resp.json()["code"] == "filesystem_imported_read_only" + + +# --- filesystem scanner lifecycle (#213 §Filesystem scanner) --- + + +@pytest.fixture +def import_config(tmp_path: Path) -> Config: + return Config( + data_dir=tmp_path / "data", + backend_type="sqlite", + base_url="http://localhost:8084", + wake_models_dir=WAKE_MODELS_DIR if _wake_models_available() else None, + pre_roll_ms=2000, + model_import_dir=tmp_path / "import", + ) + + +@pytest.fixture +def import_client(import_config: Config): + from fastapi.testclient import TestClient as _TC + + with _TC(create_app(import_config)) as c: + yield c + + +def _write_import( + import_dir: Path, + *, + name: str = "hey_jarvis_v1.tflite", + version: str = "v1", + blob: bytes = b"fake-tflite-blob", + engine: str = "microwakeword", +) -> None: + import json + + artifact = import_dir / name + artifact.write_bytes(blob) + sidecar = artifact.with_name(artifact.name + ".excita.json") + sidecar.write_text( + json.dumps( + { + "engine": engine, + "phrase_name": "hey jarvis", + "version": version, + "engine_phrase_key": Path(name).stem, + } + ) + ) + + +def test_scan_discovers_new_sidecars(import_client: TestClient) -> None: + import_dir = import_client.app.state.config.model_import_dir + import_dir.mkdir(parents=True, exist_ok=True) + _write_import(import_dir) + + scan = import_client.post("/models/scan") + assert scan.status_code == 200 + assert len(scan.json()["imported_ids"]) == 1 + + models = import_client.get("/models").json() + assert len(models) == 1 + row = models[0] + assert row["source"] == "filesystem" + assert row["filesystem_path"] == "hey_jarvis_v1.tflite" + + +def test_scan_bumps_version_to_new_row(import_client: TestClient) -> None: + """Version bump = new model row; previous row keeps its metrics and + deploy history (user story 13).""" + import_dir = import_client.app.state.config.model_import_dir + import_dir.mkdir(parents=True, exist_ok=True) + _write_import(import_dir, version="v3") + assert len(import_client.post("/models/scan").json()["imported_ids"]) == 1 + + _write_import(import_dir, version="v4") + scan = import_client.post("/models/scan").json() + assert len(scan["imported_ids"]) == 1 + + models = import_client.get("/models").json() + assert sorted(m["version"] for m in models) == ["v3", "v4"] + # Both rows share one filesystem_path — they're the same file's history. + assert len({m["filesystem_path"] for m in models}) == 1 + + +def test_scan_rescan_without_changes_is_stable(import_client: TestClient) -> None: + """Re-saving a file without a version bump must not mint spurious new + models (user story 14).""" + import_dir = import_client.app.state.config.model_import_dir + import_dir.mkdir(parents=True, exist_ok=True) + _write_import(import_dir) + import_client.post("/models/scan") + + again = import_client.post("/models/scan").json() + assert again["imported_ids"] == [] + assert len(import_client.get("/models").json()) == 1 + + +def test_scan_removes_rows_for_missing_files(import_client: TestClient) -> None: + """The volume is the source of truth: remove the file, the model goes + away from Excita too (user story 12, ADR-0021).""" + import os + + import_dir = import_client.app.state.config.model_import_dir + import_dir.mkdir(parents=True, exist_ok=True) + _write_import(import_dir) + import_client.post("/models/scan") + assert len(import_client.get("/models").json()) == 1 + + os.remove(import_dir / "hey_jarvis_v1.tflite") + scan = import_client.post("/models/scan").json() + assert scan["removed_ids"], "missing file must soft-delete its row" + assert import_client.get("/models").json() == [] + + +def test_scan_skips_malformed_sidecar_without_partial_register( + import_client: TestClient, +) -> None: + + import_dir = import_client.app.state.config.model_import_dir + import_dir.mkdir(parents=True, exist_ok=True) + (import_dir / "broken.onnx").write_bytes(b"blob") + (import_dir / "broken.onnx.excita.json").write_text("{not json") + + scan = import_client.post("/models/scan").json() + assert scan["errors"] >= 1 + assert import_client.get("/models").json() == [] + + +def test_scan_boot_runs_on_startup(import_config: Config) -> None: + """First-boot deployments come up with a usable wake word before the + operator ever opens the UI (user story 9).""" + import_config.model_import_dir.mkdir(parents=True, exist_ok=True) + _write_import(import_config.model_import_dir) + # Files land before the app boots; the lifespan scan must find them + # with no explicit scan call. + with TestClient(create_app(import_config)) as boot_client: + models = boot_client.get("/models").json() + assert len(models) == 1 + assert models[0]["source"] == "filesystem" + + + +# --- debug scoring (#213 story 5) --- + + +@requires_wake_models +def test_debug_score_returns_curve(client: TestClient) -> None: + """Score a stored clip against an imported openWakeWord model through + the app seam — the regression-check loop before flashing anything.""" + phrase_id = _create_phrase(client) + clip = _upload(client, phrase_id, (AUDIO_DIR / "hey_jarvis.wav").read_bytes()) + resp = _import_model( + client, + metadata=_import_metadata( + engine="openwakeword", + version="v0.1", + engine_phrase_key="hey_jarvis", + ), + artifact=HEY_JARVIS_MODEL.read_bytes(), + filename="hey_jarvis_v0.1.onnx", + ) + assert resp.status_code == 201, resp.text + model_id = resp.json()["id"] + + score = client.post( + "/debug/score", json={"clip_id": clip["id"], "model_id": model_id} + ) + assert score.status_code == 200, score.text + results = score.json() + assert len(results) == 1 + assert results[0]["model_id"] == model_id + assert results[0]["engine"] == "openwakeword" + curve = results[0]["curve"] + assert curve, "real wake audio must produce a non-empty curve" + assert all(0.0 <= v <= 1.0 for v in curve) + assert max(curve) >= 0.5, "hey_jarvis fixture should peak on its own model" + + +def test_debug_score_unknown_clip_404s(client: TestClient) -> None: + resp = client.post("/debug/score", json={"clip_id": "nope", "model_id": "nope"}) + assert resp.status_code == 404 + + +# --- deploy targets (#213 §Deploy targets) --- + + +def _create_target(client: TestClient, kind: str, config: dict) -> dict: + resp = client.post("/deploy_targets", json={"kind": kind, "config": config}) + assert resp.status_code == 201, resp.text + return resp.json() + + +def test_create_and_list_deploy_target(client: TestClient, tmp_path: Path) -> None: + target = _create_target( + client, "file", {"directory": str(tmp_path / "out")} + ) + assert target["kind"] == "file" + assert target["current_model_id"] is None + listed = client.get("/deploy_targets").json() + assert [t["id"] for t in listed] == [target["id"]] + + +def test_create_deploy_target_rejects_unknown_kind(client: TestClient) -> None: + resp = client.post( + "/deploy_targets", json={"kind": "carrier_pigeon", "config": {}} + ) + assert resp.status_code == 422 + + +def test_create_file_target_requires_directory(client: TestClient) -> None: + resp = client.post("/deploy_targets", json={"kind": "file", "config": {}}) + assert resp.status_code == 422 + + +def test_publish_to_file_target_writes_package( + client: TestClient, tmp_path: Path +) -> None: + """Story 23: deploying is one call — select the model, bytes land.""" + out_dir = tmp_path / "fleet" + model = _import_model( + client, + metadata=_import_metadata(version="v4"), + artifact=b"MZ-v4-firmware-blob", + filename="hey_jarvis_v4.tflite", + ).json() + target = _create_target(client, "file", {"directory": str(out_dir)}) + + resp = client.post( + f"/deploy_targets/{target['id']}/publish", + json={"model_id": model["id"]}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["current_model_id"] == model["id"] + assert body["last_publish_status"] == "ok" + + written = list(out_dir.iterdir()) + assert len(written) == 1 + assert written[0].suffix == ".tflite" + assert written[0].read_bytes() == b"MZ-v4-firmware-blob" + + +def test_publish_http_push_carries_excita_headers(client: TestClient) -> None: + """The receiving service learns what it just got without parsing the + blob (#213 §Deploy targets).""" + import threading + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + captured: list[dict] = [] + + class Recorder(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 - http.server API + length = int(self.headers.get("Content-Length", "0")) + captured.append( + { + "headers": dict(self.headers), + "body": self.rfile.read(length), + } + ) + self.send_response(200) + self.end_headers() + + server = ThreadingHTTPServer(("127.0.0.1", 0), Recorder) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + url = f"http://127.0.0.1:{server.server_address[1]}/wake-word/model" + target = _create_target(client, "http_push", {"url": url}) + model = _import_model( + client, + metadata=_import_metadata(version="v2"), + artifact=b"nww-onnx-bytes", + filename="hey_jarvis_v2.onnx", + ).json() + + resp = client.post( + f"/deploy_targets/{target['id']}/publish", + json={"model_id": model["id"]}, + ) + assert resp.status_code == 200, resp.text + + assert len(captured) == 1 + req = captured[0] + assert req["headers"].get("X-Excita-Engine") == "microwakeword" + assert req["headers"].get("X-Excita-Phrase") == "hey jarvis" + assert req["headers"].get("X-Excita-Version") == "v2" + assert req["body"] == b"nww-onnx-bytes" + + view = client.get(f"/deploy_targets/{target['id']}").json() + assert view["last_publish_status"] == "ok" + finally: + server.shutdown() + server.server_close() + + +def test_publish_failed_push_keeps_selection(client: TestClient) -> None: + """A failed publish does not roll back the DB row — the operator sees + 'selected, last push failed' and can retry the same call (spec 0011).""" + target = _create_target( + client, "http_push", {"url": "http://127.0.0.1:9/unreachable"} + ) + model = _import_model( + client, + metadata=_import_metadata(engine="nanowakeword"), + artifact=b"onnx", + filename="hey_jarvis_nww.onnx", + ).json() + + resp = client.post( + f"/deploy_targets/{target['id']}/publish", json={"model_id": model["id"]} + ) + assert resp.status_code == 200 + view = resp.json() + assert view["current_model_id"] == model["id"], "selection must stick" + assert view["last_publish_status"] == "failed" + assert view["last_publish_error"] + + view = client.get(f"/deploy_targets/{target['id']}").json() + assert view["current_model_id"] == model["id"] + + +def test_publish_unknown_target_or_model_404s(client: TestClient) -> None: + assert ( + client.post( + "/deploy_targets/nope/publish", json={"model_id": "also-nope"} + ).status_code + == 404 + ) + target = _create_target(client, "file", {"directory": "/tmp/excita-out"}) + assert ( + client.post( + f"/deploy_targets/{target['id']}/publish", + json={"model_id": "missing"}, + ).status_code + == 404 + ) + + def test_upload_to_missing_phrase_404s(client: TestClient) -> None: resp = client.post( "/clips", @@ -385,3 +1004,97 @@ def test_upload_to_missing_phrase_404s(client: TestClient) -> None: files={"file": ("x.wav", _wav_bytes(), "audio/wav")}, ) assert resp.status_code == 404 + + +# --- microWakeWord / nanoWakeWord adapter coverage --- +# +# Both adapters are exercised through the app seam wherever possible. The +# µWW score() test stays narrow because the app-level suite must not carry +# a TFLite blob fixture (#213 §Testing decisions); both gates follow +# the `_wake_models_available()` pattern — drop the artifacts in place and +# the tests light up. + + +MWW_MODEL = WAKE_MODELS_DIR / "hey_jarvis_v0.1.tflite" +NWW_MODEL = WAKE_MODELS_DIR / "hey_jarvis_v0.1.nww.onnx" + + +def _microwakeword_ready() -> bool: + if not MWW_MODEL.exists(): + return False + import importlib.util + + return importlib.util.find_spec("tflite_runtime") is not None + + +requires_microwakeword = pytest.mark.skipif( + not _microwakeword_ready(), + reason=( + "µWW artifact or tflite-runtime missing " + f"(expected {MWW_MODEL.name} + pip install tflite-runtime)" + ), +) + + +def requires_nanowakeword(fn): # noqa: ANN001, ANN201 - plain decorator + + try: + import nanowakeword # noqa: F401 + + package_ok = True + except ImportError: + package_ok = False + return pytest.mark.skipif( + package_ok is False or not NWW_MODEL.exists(), + reason=f"nanoWakeWord artifact missing (expected {NWW_MODEL.name})", + )(fn) + + +@requires_microwakeword +def test_microwakeword_score_curve_over_canned_wav() -> None: + """Narrow unit seam: canned WAV + known µWW artifact → per-hop curve.""" + import wave as _wave + + from excita.engines.microwakeword import MicroWakeWordEngine + + with _wave.open(str(AUDIO_DIR / "hey_jarvis.wav")) as w: + assert w.getframerate() == 16000 and w.getnchannels() == 1 + audio = w.readframes(w.getnframes()) + + curve = MicroWakeWordEngine().score(audio, str(MWW_MODEL)) + assert curve, "real wake audio must produce a non-empty curve" + assert all(0.0 <= v <= 1.0 for v in curve) + + +@requires_nanowakeword +def test_arm_nanowakeword_and_feed_hey_jarvis(client: TestClient) -> None: + """End-to-end at the app seam (#213 §Testing decisions): arm a + nanoWakeWord detector, feed the fixture in sub-chunk frames so the + residual buffer is exercised, expect a fire.""" + phrase_id = _create_phrase(client) + arm = client.post( + "/detectors", + json={ + "phrase_id": phrase_id, + "model_ref": str(NWW_MODEL), + "source_device": "satellite", + "engine": "nanowakeword", + }, + ) + assert arm.status_code == 201, arm.text + assert arm.json()["engine"] == "nanowakeword" + + with wave.open(str(AUDIO_DIR / "hey_jarvis.wav")) as w: + pcm = w.readframes(w.getnframes()) + + total_fires = 0 + for offset in range(0, len(pcm), 640): + resp = client.post( + "/v1/audio/satellite/frames", content=pcm[offset : offset + 640] + ) + assert resp.status_code == 202 + total_fires += resp.json()["fires"] + assert total_fires >= 1, "hey_jarvis fixture must produce at least one fire" + + events = client.get("/v1/wake-events/recent").json() + assert events and events[0]["phrase_id"] == phrase_id From a6c33aeb2fd06e1a135eeee363ae6c1662a2d7e7 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Mon, 24 Aug 2026 22:57:30 -0600 Subject: [PATCH 2/3] refactor(excita): address automated review on PR #218 - 501 capability-gap bodies are built from authored reason sentences (engines.base.gap_reason) instead of str(exception), so no exception internals can reach a response body (CodeQL py/stack-trace-exposure); the caught error is logged server-side with engine/capability context. - Adapters raise NotSupportedError(gap_reason(...)) so their messages and the HTTP bodies share one source of truth. - Backend protocol methods use explicit raise NotImplementedError stubs instead of bare '...' (CodeQL statement-has-no-effect). - Tests: no side-effecting calls inside assert expressions. --- services/excita/app.py | 14 +++- services/excita/backend.py | 99 +++++++++++++++++------- services/excita/engines/__init__.py | 2 + services/excita/engines/base.py | 27 +++++++ services/excita/engines/microwakeword.py | 13 +--- services/excita/engines/nanowakeword.py | 7 +- services/excita/test_app.py | 6 +- 7 files changed, 123 insertions(+), 45 deletions(-) diff --git a/services/excita/app.py b/services/excita/app.py index a531194..ce63c6d 100644 --- a/services/excita/app.py +++ b/services/excita/app.py @@ -67,6 +67,7 @@ OpenWakeWordEngine, WakeWordEngine, capability_view, + gap_reason, ) from .supervisor import DetectorSupervisor, bindings_view @@ -368,15 +369,24 @@ def _capability_missing( """Structured 501 body for engine capability gaps (ADR-0023). The frontend keys on `code` and renders `message` as a tooltip; it - never parses error text to figure out what an engine can't do. + never parses error text to figure out what an engine can't do. The + body carries only the authored reason sentences from + `engines.base.gap_reason` — exception internals stay in the server + log, never in a response. """ + LOG.info( + "engine capability gap engine=%s capability=%s detail=%s", + kind.value, + capability, + error, + ) return JSONResponse( status_code=501, content={ "code": "engine_capability_missing", "engine": kind.value, "capability": capability, - "message": str(error), + "message": gap_reason(kind, capability), }, ) diff --git a/services/excita/backend.py b/services/excita/backend.py index 604dad5..32e4a26 100644 --- a/services/excita/backend.py +++ b/services/excita/backend.py @@ -96,54 +96,100 @@ class Label: class Backend(Protocol): - async def close(self) -> None: ... + """Interface only — every body is a stub. CodeQL's no-effect rule is + satisfied with explicit `raise NotImplementedError` bodies rather than + bare `...`.""" - def list_phrases(self) -> list[Phrase]: ... - def get_phrase(self, phrase_id: str) -> Phrase | None: ... - def insert_phrase(self, phrase: Phrase) -> None: ... + async def close(self) -> None: + raise NotImplementedError + + def list_phrases(self) -> list[Phrase]: + raise NotImplementedError + + def get_phrase(self, phrase_id: str) -> Phrase | None: + raise NotImplementedError + + def insert_phrase(self, phrase: Phrase) -> None: + raise NotImplementedError def list_clips( self, phrase_id: str | None = None, verdict: str | None = None, limit: int = 100, - ) -> list[Clip]: ... - def get_clip(self, clip_id: str) -> Clip | None: ... - def get_clip_by_sha256(self, phrase_id: str, sha256: str) -> Clip | None: ... - def insert_clip(self, clip: Clip) -> None: ... + ) -> list[Clip]: + raise NotImplementedError + + def get_clip(self, clip_id: str) -> Clip | None: + raise NotImplementedError - def get_label(self, clip_id: str, labeller: str) -> Label | None: ... - def upsert_label(self, label: Label) -> None: ... + def get_clip_by_sha256(self, phrase_id: str, sha256: str) -> Clip | None: + raise NotImplementedError - def get_phrase_by_name(self, name: str) -> Phrase | None: ... + def insert_clip(self, clip: Clip) -> None: + raise NotImplementedError + + def get_label(self, clip_id: str, labeller: str) -> Label | None: + raise NotImplementedError + + def upsert_label(self, label: Label) -> None: + raise NotImplementedError + + def get_phrase_by_name(self, name: str) -> Phrase | None: + raise NotImplementedError def list_models( self, phrase_id: str | None = None, source: str | None = None, include_deleted: bool = False, - ) -> list[Model]: ... + ) -> list[Model]: + raise NotImplementedError + def get_model( self, model_id: str, include_deleted: bool = False - ) -> Model | None: ... + ) -> Model | None: + raise NotImplementedError + def get_filesystem_model( self, filesystem_path: str, version: str - ) -> Model | None: ... + ) -> Model | None: + raise NotImplementedError + def get_model_by_version( self, phrase_id: str, engine: str, version: str - ) -> Model | None: ... + ) -> Model | None: + raise NotImplementedError + def promote_upload_model( self, model_id: str, filesystem_path: str, mtime: str, size: int - ) -> None: ... - def active_filesystem_paths(self) -> set[str]: ... - def insert_model(self, model: Model) -> None: ... - def touch_model(self, model_id: str, mtime: str, size: int) -> None: ... - def resurrect_model(self, model_id: str, mtime: str, size: int) -> None: ... - def soft_delete_model(self, model_id: str) -> None: ... - - def list_deploy_targets(self) -> list[DeployTarget]: ... - def get_deploy_target(self, target_id: str) -> DeployTarget | None: ... - def insert_deploy_target(self, target: DeployTarget) -> None: ... + ) -> None: + raise NotImplementedError + + def active_filesystem_paths(self) -> set[str]: + raise NotImplementedError + + def insert_model(self, model: Model) -> None: + raise NotImplementedError + + def touch_model(self, model_id: str, mtime: str, size: int) -> None: + raise NotImplementedError + + def resurrect_model(self, model_id: str, mtime: str, size: int) -> None: + raise NotImplementedError + + def soft_delete_model(self, model_id: str) -> None: + raise NotImplementedError + + def list_deploy_targets(self) -> list[DeployTarget]: + raise NotImplementedError + + def get_deploy_target(self, target_id: str) -> DeployTarget | None: + raise NotImplementedError + + def insert_deploy_target(self, target: DeployTarget) -> None: + raise NotImplementedError + def record_publish( self, target_id: str, @@ -151,7 +197,8 @@ def record_publish( status: str, error: str | None, at: str, - ) -> None: ... + ) -> None: + raise NotImplementedError _SCHEMA = """ diff --git a/services/excita/engines/__init__.py b/services/excita/engines/__init__.py index 6848301..f167cf5 100644 --- a/services/excita/engines/__init__.py +++ b/services/excita/engines/__init__.py @@ -14,6 +14,7 @@ NullEngine, WakeWordEngine, capability_view, + gap_reason, ) from .microwakeword import MicroWakeWordEngine from .nanowakeword import NanoWakeWordEngine @@ -30,4 +31,5 @@ "OpenWakeWordEngine", "WakeWordEngine", "capability_view", + "gap_reason", ] diff --git a/services/excita/engines/base.py b/services/excita/engines/base.py index c92ceb7..ba90a24 100644 --- a/services/excita/engines/base.py +++ b/services/excita/engines/base.py @@ -18,6 +18,33 @@ class EngineKind(str, Enum): # declared alongside `load` — no adapter supports one without the other. CAPABILITIES = ("load", "feed", "score", "train", "package") +# Operator-facing reason sentences for the permanent capability gaps +# (ADR-0023: say *why*, not just *what*). Authored here as literals so both +# the adapters' NotSupportedError messages and the HTTP 501 bodies carry the +# same text — and so no exception internals ever reach a response body. +_GAP_REASONS: dict[tuple[str, str], str] = { + ("microwakeword", "load"): ( + "microWakeWord does not run live host-side detection in Excita; " + "detection happens on the ESP32." + ), + ("microwakeword", "train"): ( + "microWakeWord training does not run in-process; configure " + "EXCITA_TRAIN_WORKER_URL to route training to an external worker." + ), + ("nanowakeword", "train"): ( + "nanoWakeWord training does not run in-process; configure " + "EXCITA_TRAIN_WORKER_URL to route training to an external worker." + ), +} + + +def gap_reason(kind: EngineKind, capability: str) -> str: + """Static operator-facing sentence for a capability gap.""" + reason = _GAP_REASONS.get((kind.value, capability)) + if reason is not None: + return reason + return f"{kind.value} does not support {capability}." + class NotSupportedError(RuntimeError): """Raised by adapters for operations they cannot perform. diff --git a/services/excita/engines/microwakeword.py b/services/excita/engines/microwakeword.py index 87a8bdd..daee836 100644 --- a/services/excita/engines/microwakeword.py +++ b/services/excita/engines/microwakeword.py @@ -24,7 +24,7 @@ import numpy as np -from .base import Detector, EngineKind, NotSupportedError +from .base import Detector, EngineKind, NotSupportedError, gap_reason try: # pragma: no cover - import guard, exercised only on wheels-less hosts from tflite_runtime.interpreter import Interpreter as _TfLiteInterpreter @@ -46,11 +46,7 @@ class MicroWakeWordEngine: package_targets = ("tflite_micro",) def load(self, model_ref: str, phrase_id: str) -> Detector: - raise NotSupportedError( - "microwakeword does not run live host-side detection in Excita; " - "detection happens on the ESP32. Use score() against stored clips " - "or package() for the device." - ) + raise NotSupportedError(gap_reason(self.kind, "load")) def score(self, audio: bytes, model_ref: str) -> list[float]: """Per-hop scores across a full 16 kHz mono PCM WAV.""" @@ -90,10 +86,7 @@ def score(self, audio: bytes, model_ref: str) -> list[float]: return curve def train(self, dataset_snapshot_id: str, base: str | None) -> str: - raise NotSupportedError( - "microwakeword training does not run in-process; configure " - "EXCITA_TRAIN_WORKER_URL to route training to an external worker." - ) + raise NotSupportedError(gap_reason(self.kind, "train")) def package(self, model_ref: str, target_kind: str) -> bytes: if target_kind != "tflite_micro": diff --git a/services/excita/engines/nanowakeword.py b/services/excita/engines/nanowakeword.py index 0a90c56..dfa586f 100644 --- a/services/excita/engines/nanowakeword.py +++ b/services/excita/engines/nanowakeword.py @@ -24,7 +24,7 @@ import numpy as np from nanowakeword.interpreter import NanoInterpreter -from .base import Detector, EngineKind, NotSupportedError +from .base import Detector, EngineKind, NotSupportedError, gap_reason SAMPLE_RATE = 16000 CHUNK_SAMPLES = 1280 @@ -133,10 +133,7 @@ def score(self, audio: bytes, model_ref: str) -> list[float]: return curve def train(self, dataset_snapshot_id: str, base: str | None) -> str: - raise NotSupportedError( - "nanowakeword training does not run in-process; configure " - "EXCITA_TRAIN_WORKER_URL to route training to an external worker." - ) + raise NotSupportedError(gap_reason(self.kind, "train")) def package(self, model_ref: str, target_kind: str) -> bytes: if target_kind != "onnx": diff --git a/services/excita/test_app.py b/services/excita/test_app.py index 497262b..0aeee97 100644 --- a/services/excita/test_app.py +++ b/services/excita/test_app.py @@ -633,7 +633,8 @@ def test_same_phrase_across_engines_is_one_row_set(client: TestClient) -> None: def test_delete_upload_model_soft_deletes(client: TestClient) -> None: model_id = _import_model(client, metadata=_import_metadata()).json()["id"] - assert client.delete(f"/models/{model_id}").status_code == 204 + delete_resp = client.delete(f"/models/{model_id}") + assert delete_resp.status_code == 204 assert client.get("/models").json() == [] assert client.get(f"/models/{model_id}").status_code == 404 @@ -732,7 +733,8 @@ def test_scan_bumps_version_to_new_row(import_client: TestClient) -> None: import_dir = import_client.app.state.config.model_import_dir import_dir.mkdir(parents=True, exist_ok=True) _write_import(import_dir, version="v3") - assert len(import_client.post("/models/scan").json()["imported_ids"]) == 1 + first_scan = import_client.post("/models/scan").json() + assert len(first_scan["imported_ids"]) == 1 _write_import(import_dir, version="v4") scan = import_client.post("/models/scan").json() From d70b55267115ca7e1e564777f9b65a2276ba19c1 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Mon, 24 Aug 2026 23:07:25 -0600 Subject: [PATCH 3/3] fix: upgrade h2 to 0.4.19 to resolve vulnerability --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1402342..782fd2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1473,7 +1473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1742,9 +1742,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2811,7 +2811,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3146,7 +3146,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3777,7 +3777,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4695,7 +4695,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]]