"""Ausführung einer RouteDecision auf Stufe 0–3 und Erkennung von Laufzeit-Signalen.""" from __future__ import annotations from typing import Any from pydantic import BaseModel, Field from . import programs from .classifier import EmbeddingClassifier from .embeddings import Embedder from .llm import ChatClient, ContextOverflowError, LLMError from .problems import Signal, extract_json, is_degenerate from .router import RouteDecision, system_prompt from .skills import Skill, SkillLibrary class ExecutionResult(BaseModel): ok: bool output: str = "" signals: list[Signal] = Field(default_factory=list) messages: list[dict[str, str]] = Field(default_factory=list) finish_reason: str | None = None usage: dict[str, Any] = Field(default_factory=dict) error: str | None = None label: str | None = None confidence: float | None = None def build_messages(skill: Skill | None, decision: RouteDecision, text: str) -> list[dict[str, str]]: system = system_prompt(skill, decision.prompt_variant) messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": text}) return messages class Executor: def __init__(self, library: SkillLibrary, chat: ChatClient, embedder: Embedder, classifiers: dict[str, EmbeddingClassifier] | None = None): self.library = library self.chat = chat self.embedder = embedder self.classifiers = classifiers or {} def execute(self, decision: RouteDecision, text: str) -> ExecutionResult: if decision.status != "routed": return ExecutionResult(ok=False, error=f"Entscheidung nicht ausführbar: {decision.status}") skill = self.library.get(decision.skill) if decision.skill else None if decision.stage == 3: return self._classify(skill, text) if decision.stage == 2: return self._program(skill, text) return self._llm(skill, decision, text) def _classify(self, skill: Skill | None, text: str) -> ExecutionResult: assert skill and skill.meta.classifier clf = self.classifiers.get(skill.meta.classifier) if clf is None: return ExecutionResult(ok=False, signals=[Signal.PROGRAM_ERROR], error=f"Classifier {skill.meta.classifier} nicht geladen") label, confidence = clf.predict(self.embedder.embed([text])[0]) if confidence < skill.meta.min_confidence: return ExecutionResult(ok=False, output=label, label=label, confidence=confidence, signals=[Signal.CLASSIFIER_LOW_CONFIDENCE]) return ExecutionResult(ok=True, output=label, label=label, confidence=confidence) def _program(self, skill: Skill | None, text: str) -> ExecutionResult: assert skill and skill.meta.program try: output = programs.get(skill.meta.program)(text) except Exception as exc: # noqa: BLE001 – Programmfehler sind ein Signal, kein Absturz return ExecutionResult(ok=False, signals=[Signal.PROGRAM_ERROR], error=f"{type(exc).__name__}: {exc}") return ExecutionResult(ok=True, output=output) def _llm(self, skill: Skill | None, decision: RouteDecision, text: str) -> ExecutionResult: messages = build_messages(skill, decision, text) try: res = self.chat.chat(decision.model or "", messages, max_tokens=decision.max_output_tokens) except ContextOverflowError as exc: return ExecutionResult(ok=False, messages=messages, signals=[Signal.CONTEXT_OVERFLOW], error=str(exc)) except LLMError as exc: return ExecutionResult(ok=False, messages=messages, signals=[Signal.ENDPOINT_ERROR], error=str(exc)) signals: list[Signal] = [] if res.finish_reason == "length": signals.append(Signal.OUTPUT_TRUNCATED) if is_degenerate(res.content): signals.append(Signal.DEGENERATE_OUTPUT) if skill is not None and skill.meta.output.format == "json": try: extract_json(res.content) except ValueError: signals.append(Signal.INVALID_FORMAT) return ExecutionResult(ok=not signals, output=res.content, signals=signals, messages=messages, finish_reason=res.finish_reason, usage=res.usage)