diff --git a/core/src/knaster/api.py b/core/src/knaster/api.py index cadc285..2f29f2b 100644 --- a/core/src/knaster/api.py +++ b/core/src/knaster/api.py @@ -71,7 +71,8 @@ def create_app(system: System | None = None, start_prober: bool | None = None) - @app.get("/health") def health() -> dict[str, Any]: - return {"status": "ok", "skills": len(system.library.all()), "dry_run": system.settings.dry_run} + return {"status": "ok", "skills": len(system.library.all()), "dry_run": system.settings.dry_run, + "embedding_index": "ok" if system.router.index_error is None else system.router.index_error} @app.get("/metrics") def metrics() -> Response: diff --git a/core/src/knaster/router.py b/core/src/knaster/router.py index 846e42e..556a8f8 100644 --- a/core/src/knaster/router.py +++ b/core/src/knaster/router.py @@ -79,11 +79,18 @@ class Router: self.config = config or RouterConfig() self.rng = rng or random.Random() self.index = SkillIndex(embedder) + self.index_error: str | None = None self.refresh() - def refresh(self) -> None: - """Index neu aufbauen (nach Skill-Änderungen).""" - self.index.rebuild({s.name: s.index_texts() for s in self.library.all()}) + def refresh(self) -> bool: + """Index neu aufbauen (nach Skill-Änderungen). Fällt das Embedding aus, bleibt der Service lauffähig.""" + try: + self.index.rebuild({s.name: s.index_texts() for s in self.library.all()}) + except Exception as exc: # noqa: BLE001 – Embedding-Endpunkt nicht erreichbar + self.index_error = f"{type(exc).__name__}: {exc}" + return False + self.index_error = None + return True # --- Routing ---------------------------------------------------------------------------- def route(self, text: str, hint_skill: str | None = None) -> RouteDecision: @@ -123,7 +130,17 @@ class Router: reasons.append("Keine routbaren Skills") signals.append(Signal.NO_SKILL) return None, None - qvec = self.embedder.embed([text])[0] + if self.index_error is not None and not self.refresh(): + reasons.append(f"Embedding nicht verfügbar ({self.index_error}) – Stufe 0") + signals.append(Signal.NO_SKILL) + return None, None + try: + qvec = self.embedder.embed([text])[0] + except Exception as exc: # noqa: BLE001 + self.index_error = f"{type(exc).__name__}: {exc}" + reasons.append(f"Embedding nicht verfügbar ({self.index_error}) – Stufe 0") + signals.append(Signal.NO_SKILL) + return None, None hits = self.index.search(qvec, k=self.config.top_k, only=routable) if not hits: signals.append(Signal.NO_SKILL) diff --git a/core/tests/test_router.py b/core/tests/test_router.py index b6d1ae6..8d110a6 100644 --- a/core/tests/test_router.py +++ b/core/tests/test_router.py @@ -131,3 +131,25 @@ def test_conflict_with_reranker_trusts_reranker(registry, library, embedder): assert Signal.SKILL_CONFLICT in d.signals assert "Reranking aktiv" in d.reasons assert d.skill == "rechnung-extrahieren" and d.stage == 1 + + +class FlakyEmbedder: + def __init__(self, inner): + self.inner = inner + self.down = True + + def embed(self, texts): + if self.down: + raise ConnectionError("embed-default weg") + return self.inner.embed(texts) + + +def test_embedding_outage_degrades_to_strong_and_recovers(registry, library, embedder): + flaky = FlakyEmbedder(embedder) + r = Router(registry, library, flaky) # Start ohne Embedding darf nicht abstürzen + assert r.index_error + d = r.route(RECHNUNG) + assert d.stage == 0 and any("Embedding nicht verfügbar" in x for x in d.reasons) + flaky.down = False + d = r.route(RECHNUNG) + assert d.stage == 1 and r.index_error is None