core: Router bleibt bei Embedding-Ausfall lauffähig (Stufe 0) und erholt sich selbst
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user