core: Registry, Skills, Router, Executor, Verifier, Runner mit Tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 00:36:21 +02:00
co-authored by Claude Opus 5.5
parent fef930d176
commit f79e2a8556
24 changed files with 2581 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""knaster – Skill-Destillation von starken zu schwachen LLMs."""
__version__ = "0.1.0"
+36
View File
@@ -0,0 +1,36 @@
"""Token-Budget: Passt Prompt + Ausgabe in den effektiven Kontext eines Slots?"""
from __future__ import annotations
import math
from collections.abc import Callable
from dataclasses import dataclass
MESSAGE_OVERHEAD = 64
Tokenizer = Callable[[str], int]
def estimate_tokens(text: str) -> int:
"""Konservative Schätzung (eher zu hoch): ~3 Zeichen pro Token."""
return math.ceil(len(text) / 3)
@dataclass(frozen=True)
class Budget:
prompt_tokens: int
output_tokens: int
limit: int
@property
def required(self) -> int:
return self.prompt_tokens + self.output_tokens + MESSAGE_OVERHEAD
@property
def fits(self) -> bool:
return self.required <= self.limit
def compute(system: str, user: str, max_output_tokens: int, limit: int,
tokenizer: Tokenizer = estimate_tokens) -> Budget:
return Budget(tokenizer(system) + tokenizer(user), max_output_tokens, limit)
+39
View File
@@ -0,0 +1,39 @@
"""Kalibrierung des effektiven Kontexts aus beobachteten Pass-Rates nach Prompt-Länge."""
from __future__ import annotations
from collections.abc import Iterable
DEFAULT_BUCKETS = (2048, 4096, 8192, 16384, 32768, 65536, 131072)
def effective_context(
observations: Iterable[tuple[int, bool]],
*,
target: float = 0.9,
min_n: int = 20,
buckets: tuple[int, ...] = DEFAULT_BUCKETS,
) -> int | None:
"""observations: (prompt_tokens, bestanden).
Liefert die Obergrenze des größten Buckets, bis zu dem alle Buckets mit genug Daten
die Ziel-Pass-Rate erreichen. Buckets ohne genug Daten unterbrechen die Kette nicht,
zählen aber auch nicht als Nachweis. Ohne Nachweis: None.
"""
counts = {b: [0, 0] for b in buckets} # [n, passed]
for tokens, passed in observations:
for b in buckets:
if tokens <= b:
counts[b][0] += 1
counts[b][1] += int(passed)
break
result = None
for b in buckets:
n, ok = counts[b]
if n < min_n:
continue
if ok / n >= target:
result = b
else:
break
return result
+44
View File
@@ -0,0 +1,44 @@
"""Fälle und Präferenzpaare: modellneutral speichern, deterministisch splitten, exportieren."""
from __future__ import annotations
import hashlib
import json
from collections.abc import Iterator
from typing import Any
from .store import Store
EVAL_SHARE_PERCENT = 20
def split_for(text: str) -> str:
"""Fester Split pro Eingabe: gleiche Eingabe landet immer im selben Split."""
bucket = int(hashlib.sha256(text.encode("utf-8")).hexdigest(), 16) % 100
return "eval" if bucket < EVAL_SHARE_PERCENT else "train"
def export_sft(store: Store, *, cluster: str, split: str = "train") -> Iterator[str]:
"""JSONL für Supervised Finetuning: nur unabhängig verifizierte Fälle."""
for case in reversed(store.list_cases(cluster=cluster, split=split, passed=True)):
messages = list(case["messages"] or [])
messages.append({"role": "assistant", "content": case["output"]})
meta = {k: case[k] for k in ("id", "skill", "skill_version", "stage", "model", "origin")}
yield json.dumps({"messages": messages, "meta": meta}, ensure_ascii=False)
def export_preferences(store: Store, *, cluster: str, split: str = "train") -> Iterator[str]:
for pair in store.list_preference_pairs(cluster=cluster):
if pair["split"] != split:
continue
yield json.dumps(
{"prompt": pair["prompt"], "chosen": pair["chosen"], "rejected": pair["rejected"]},
ensure_ascii=False,
)
def pass_rate(rows: list[dict[str, Any]]) -> float | None:
decided = [r for r in rows if r.get("passed") is not None]
if not decided:
return None
return sum(1 for r in decided if r["passed"]) / len(decided)
+50
View File
@@ -0,0 +1,50 @@
"""Stufe 3: Entscheidungen direkt auf Embeddings (Nearest Centroid mit Konfidenz)."""
from __future__ import annotations
import json
import math
from collections import defaultdict
from pathlib import Path
from .embeddings import Vector, cosine
class EmbeddingClassifier:
def __init__(self, centroids: dict[str, Vector], counts: dict[str, int] | None = None):
if not centroids:
raise ValueError("Classifier braucht mindestens eine Klasse")
self.centroids = centroids
self.counts = counts or {}
@classmethod
def train(cls, examples: list[tuple[Vector, str]]) -> EmbeddingClassifier:
sums: dict[str, Vector] = {}
counts: dict[str, int] = defaultdict(int)
for vec, label in examples:
if label not in sums:
sums[label] = [0.0] * len(vec)
sums[label] = [a + b for a, b in zip(sums[label], vec, strict=True)]
counts[label] += 1
centroids = {}
for label, total in sums.items():
norm = math.sqrt(sum(v * v for v in total)) or 1.0
centroids[label] = [v / norm for v in total]
return cls(centroids, dict(counts))
def predict(self, vec: Vector) -> tuple[str, float]:
"""Liefert (Label, Konfidenz). Konfidenz = Softmax-Abstand der besten zwei Klassen (0..1)."""
scores = sorted(((cosine(vec, c), label) for label, c in self.centroids.items()), reverse=True)
best_score, best_label = scores[0]
if len(scores) == 1:
return best_label, max(0.0, best_score)
exps = [math.exp(s * 20) for s, _ in scores] # Temperatur 0.05
return best_label, exps[0] / sum(exps)
def save(self, path: str | Path) -> None:
Path(path).write_text(json.dumps({"centroids": self.centroids, "counts": self.counts}))
@classmethod
def load(cls, path: str | Path) -> EmbeddingClassifier:
data = json.loads(Path(path).read_text())
return cls(data["centroids"], data.get("counts"))
+136
View File
@@ -0,0 +1,136 @@
"""Embedding- und Rerank-Clients sowie Skill-Index."""
from __future__ import annotations
import hashlib
import math
import re
from collections.abc import Sequence
from typing import Protocol
import httpx
Vector = list[float]
class Embedder(Protocol):
def embed(self, texts: Sequence[str]) -> list[Vector]: ...
class Reranker(Protocol):
def rerank(self, query: str, documents: Sequence[str]) -> list[float]: ...
def cosine(a: Sequence[float], b: Sequence[float]) -> float:
dot = sum(x * y for x, y in zip(a, b, strict=True))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
class HashingEmbedder:
"""Deterministischer Bag-of-Words-Embedder für Tests und als Notfall-Fallback ohne GPU/Netz."""
_WORD = re.compile(r"\w+", re.UNICODE)
def __init__(self, dim: int = 512):
self.dim = dim
def _vector(self, text: str) -> Vector:
vec = [0.0] * self.dim
for word in self._WORD.findall(text.lower()):
h = int.from_bytes(hashlib.sha1(word.encode()).digest()[:4], "big")
vec[h % self.dim] += 1.0 if (h >> 31) & 1 else -1.0
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
return [v / norm for v in vec]
def embed(self, texts: Sequence[str]) -> list[Vector]:
return [self._vector(t) for t in texts]
class OpenAIEmbedder:
"""Embeddings über ein OpenAI-kompatibles API (LiteLLM → llama.cpp/TEI)."""
def __init__(self, base_url: str, model: str, api_key: str = "", timeout: float = 30.0,
client: httpx.Client | None = None):
self.base_url = base_url.rstrip("/")
self.model = model
self.headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
self.client = client or httpx.Client(timeout=timeout)
def embed(self, texts: Sequence[str]) -> list[Vector]:
resp = self.client.post(
f"{self.base_url}/v1/embeddings",
json={"model": self.model, "input": list(texts)},
headers=self.headers,
)
resp.raise_for_status()
data = sorted(resp.json()["data"], key=lambda d: d["index"])
return [d["embedding"] for d in data]
class LlamaCppReranker:
"""Reranking über den llama.cpp-Server (`--reranking`, Endpoint /v1/rerank)."""
def __init__(self, base_url: str, model: str = "rerank-default", timeout: float = 30.0,
client: httpx.Client | None = None):
self.base_url = base_url.rstrip("/")
self.model = model
self.client = client or httpx.Client(timeout=timeout)
def rerank(self, query: str, documents: Sequence[str]) -> list[float]:
resp = self.client.post(
f"{self.base_url}/v1/rerank",
json={"model": self.model, "query": query, "documents": list(documents)},
)
resp.raise_for_status()
scores = [0.0] * len(documents)
for item in resp.json()["results"]:
scores[item["index"]] = float(item["relevance_score"])
return scores
class SkillIndex:
"""Hält pro Skill die Vektoren von Beschreibung und Triggern (wenige hundert Skills → im RAM)."""
def __init__(self, embedder: Embedder):
self.embedder = embedder
self._cache: dict[str, Vector] = {}
self._entries: dict[str, list[Vector]] = {}
def _embed_cached(self, texts: list[str]) -> list[Vector]:
missing = [t for t in texts if t not in self._cache]
if missing:
for text, vec in zip(missing, self.embedder.embed(missing), strict=True):
self._cache[text] = vec
return [self._cache[t] for t in texts]
def rebuild(self, skills: dict[str, list[str]]) -> None:
"""skills: name -> Texte (Beschreibung + Trigger)."""
all_texts = sorted({t for texts in skills.values() for t in texts})
self._embed_cached(all_texts)
self._entries = {name: [self._cache[t] for t in texts] for name, texts in skills.items()}
def vector(self, name: str) -> Vector:
return self._entries[name][0]
def search(self, query_vec: Vector, k: int = 5, only: set[str] | None = None) -> list[tuple[str, float]]:
scored = []
for name, vecs in self._entries.items():
if only is not None and name not in only:
continue
scored.append((name, max(cosine(query_vec, v) for v in vecs)))
scored.sort(key=lambda x: (-x[1], x[0]))
return scored[:k]
def pairwise_similar(self, threshold: float) -> list[tuple[str, str, float]]:
names = sorted(self._entries)
pairs = []
for i, a in enumerate(names):
for b in names[i + 1:]:
sim = cosine(self.vector(a), self.vector(b))
if sim >= threshold:
pairs.append((a, b, sim))
return pairs
+97
View File
@@ -0,0 +1,97 @@
"""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)
+71
View File
@@ -0,0 +1,71 @@
"""Chat-Client für das OpenAI-kompatible API von LiteLLM."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol
import httpx
from .problems import is_context_overflow_message
class LLMError(Exception):
"""Basisklasse; `infra=True` bedeutet: Endpunkt/Netz, nicht Modellqualität."""
infra = True
class EndpointError(LLMError):
pass
class ContextOverflowError(LLMError):
pass
@dataclass
class ChatResult:
content: str
finish_reason: str | None = None
usage: dict[str, Any] = field(default_factory=dict)
model: str | None = None
class ChatClient(Protocol):
def chat(self, model: str, messages: list[dict[str, str]], *, max_tokens: int | None = None,
temperature: float | None = None) -> ChatResult: ...
class OpenAIChatClient:
def __init__(self, base_url: str, api_key: str = "", timeout: float = 120.0,
client: httpx.Client | None = None):
self.base_url = base_url.rstrip("/")
self.headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
self.client = client or httpx.Client(timeout=timeout)
def chat(self, model: str, messages: list[dict[str, str]], *, max_tokens: int | None = None,
temperature: float | None = None) -> ChatResult:
body: dict[str, Any] = {"model": model, "messages": messages}
if max_tokens is not None:
body["max_tokens"] = max_tokens
if temperature is not None:
body["temperature"] = temperature
try:
resp = self.client.post(f"{self.base_url}/v1/chat/completions", json=body, headers=self.headers)
except httpx.HTTPError as exc:
raise EndpointError(f"{type(exc).__name__}: {exc}") from exc
if resp.status_code == 400 and is_context_overflow_message(resp.text):
raise ContextOverflowError(resp.text[:500])
if resp.status_code == 429 or resp.status_code >= 500:
raise EndpointError(f"HTTP {resp.status_code}: {resp.text[:300]}")
if resp.status_code >= 400:
raise EndpointError(f"HTTP {resp.status_code}: {resp.text[:300]}")
data = resp.json()
choice = data["choices"][0]
return ChatResult(
content=choice["message"].get("content") or "",
finish_reason=choice.get("finish_reason"),
usage=data.get("usage") or {},
model=data.get("model"),
)
+71
View File
@@ -0,0 +1,71 @@
"""Signale (Fehlerklassen) und ihre Erkennung."""
from __future__ import annotations
import json
import re
from enum import StrEnum
class Signal(StrEnum):
NO_SKILL = "no_skill"
SKILL_CONFLICT = "skill_conflict"
SLOT_UNAVAILABLE = "slot_unavailable"
CONTEXT_EXCEEDED = "context_exceeded" # präventiv vom Router erkannt
COMPACT_PROMPT = "compact_prompt" # Ausweichen auf kompakte Skill-Variante
LARGER_SLOT = "larger_slot" # Ausweichen auf Slot mit mehr Kontext
NEEDS_SPLIT = "needs_split"
OUTPUT_TRUNCATED = "output_truncated"
DEGENERATE_OUTPUT = "degenerate_output"
INVALID_FORMAT = "invalid_format"
CONTEXT_OVERFLOW = "context_overflow" # Engine meldet Overflow
ENDPOINT_ERROR = "endpoint_error"
VERIFIER_FAILED = "verifier_failed"
CLASSIFIER_LOW_CONFIDENCE = "classifier_low_confidence"
PROGRAM_ERROR = "program_error"
MISROUTE_SUSPECTED = "misroute_suspected"
INFRA_SIGNALS = {Signal.ENDPOINT_ERROR, Signal.CONTEXT_OVERFLOW}
QUALITY_SIGNALS = {
Signal.VERIFIER_FAILED,
Signal.INVALID_FORMAT,
Signal.DEGENERATE_OUTPUT,
Signal.OUTPUT_TRUNCATED,
Signal.CLASSIFIER_LOW_CONFIDENCE,
Signal.PROGRAM_ERROR,
}
_OVERFLOW_PATTERNS = re.compile(
r"context length|maximum context|too many tokens|context window|prompt is too long", re.IGNORECASE
)
_WORD = re.compile(r"\w+", re.UNICODE)
def is_context_overflow_message(text: str) -> bool:
return bool(_OVERFLOW_PATTERNS.search(text or ""))
def repetition_ratio(text: str, n: int = 4) -> float:
"""Anteil der n-Gramme, die Wiederholungen sind (0 = keine)."""
words = _WORD.findall(text.lower())
if len(words) < n:
return 0.0
grams = [tuple(words[i:i + n]) for i in range(len(words) - n + 1)]
return 1.0 - len(set(grams)) / len(grams)
def is_degenerate(text: str, min_words: int = 40, max_ratio: float = 0.5) -> bool:
return len(_WORD.findall(text)) >= min_words and repetition_ratio(text) > max_ratio
def extract_json(text: str) -> object:
"""Parst JSON, toleriert ```json-Codeblöcke. Wirft ValueError bei ungültigem JSON."""
stripped = text.strip()
fence = re.match(r"^```(?:json)?\s*\n(.*?)\n```\s*$", stripped, re.DOTALL)
if fence:
stripped = fence.group(1)
try:
return json.loads(stripped)
except json.JSONDecodeError as exc:
raise ValueError(str(exc)) from exc
+33
View File
@@ -0,0 +1,33 @@
"""Stufe 2: registrierte, geprüfte Programme (vom starken LLM erzeugt, vom Menschen freigegeben)."""
from __future__ import annotations
from collections.abc import Callable
Program = Callable[[str], str]
_REGISTRY: dict[str, Program] = {}
def register(name: str) -> Callable[[Program], Program]:
def deco(fn: Program) -> Program:
if name in _REGISTRY:
raise ValueError(f"Programm {name} bereits registriert")
_REGISTRY[name] = fn
return fn
return deco
def get(name: str) -> Program:
return _REGISTRY[name]
def names() -> list[str]:
return sorted(_REGISTRY)
@register("echo-upper")
def _echo_upper(text: str) -> str:
"""Beispielprogramm für Tests."""
return text.upper()
+172
View File
@@ -0,0 +1,172 @@
"""Registry: deklarative Slots/Adapter (YAML) plus Laufzeitzustand (Store)."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field
from .store import Store
from .util import load_yaml
SlotStatus = Literal["ok", "degraded", "unavailable"]
class SlotSpec(BaseModel):
name: str
tier: int
device: Literal["gpu", "cpu", "external"] = "gpu"
engine: str = "vllm"
node: str | None = None
litellm_model: str
base_model: str
max_context: int
effective_context: int | None = None
supports_lora: bool = False
fallbacks: list[str] = Field(default_factory=list)
kind: Literal["chat", "embedding", "rerank"] = "chat"
class AdapterSpec(BaseModel):
name: str
base_model: str
cluster: str
version: int = 1
status: Literal["active", "candidate", "retired"] = "active"
class Slot(BaseModel):
"""Effektive Sicht: Spezifikation + Laufzeitzustand."""
spec: SlotSpec
status: SlotStatus = "ok"
reason: str | None = None
set_by: str | None = None
calibrated_context: int | None = None
base_model_override: str | None = None
@property
def name(self) -> str:
return self.spec.name
@property
def base_model(self) -> str:
return self.base_model_override or self.spec.base_model
@property
def effective_context(self) -> int:
for value in (self.calibrated_context, self.spec.effective_context):
if value:
return min(value, self.spec.max_context)
return self.spec.max_context
@property
def available(self) -> bool:
return self.status == "ok"
class Registry:
def __init__(self, slots: dict[str, SlotSpec], adapters: list[AdapterSpec], store: Store):
self._slots = slots
self._adapters = {a.name: a for a in adapters}
self.store = store
@classmethod
def from_file(cls, path: str | Path, store: Store) -> Registry:
data = load_yaml(path)
return cls.from_dict(data, store)
@classmethod
def from_dict(cls, data: dict[str, Any], store: Store) -> Registry:
slots = {name: SlotSpec(name=name, **spec) for name, spec in (data.get("slots") or {}).items()}
adapters = [AdapterSpec(**a) for a in data.get("adapters") or []]
for spec in slots.values():
for fb in spec.fallbacks:
if fb not in slots:
raise ValueError(f"Slot {spec.name}: unbekannter Fallback {fb}")
return cls(slots, adapters, store)
# --- Slots ------------------------------------------------------------------------------
def slot_names(self) -> list[str]:
return list(self._slots)
def has_slot(self, name: str) -> bool:
return name in self._slots
def slot(self, name: str) -> Slot:
spec = self._slots[name]
state = self.store.get_slot_state(name) or {}
return Slot(
spec=spec,
status=state.get("status") or "ok",
reason=state.get("reason"),
set_by=state.get("set_by"),
calibrated_context=state.get("effective_context"),
base_model_override=state.get("base_model"),
)
def slots(self) -> list[Slot]:
return [self.slot(n) for n in self._slots]
def strong_slot(self) -> Slot:
chat = [s for s in self.slots() if s.spec.kind == "chat" and s.spec.tier == 0]
if not chat:
raise LookupError("Kein Slot mit tier 0 konfiguriert")
available = [s for s in chat if s.available]
return (available or chat)[0]
def set_status(self, name: str, status: SlotStatus, reason: str = "", set_by: str = "human") -> None:
if name not in self._slots:
raise KeyError(name)
self.store.set_slot_state(name, status=status, reason=reason, set_by=set_by)
def set_calibrated_context(self, name: str, tokens: int | None) -> None:
self.store.set_slot_state(name, effective_context=tokens)
def set_base_model(self, name: str, base_model: str | None) -> None:
self.store.set_slot_state(name, base_model=base_model)
# --- Adapter ----------------------------------------------------------------------------
def adapters(self) -> list[AdapterSpec]:
merged = {n: a.model_copy() for n, a in self._adapters.items()}
for row in self.store.all_adapter_states():
if row["name"] in merged:
if row.get("status"):
merged[row["name"]].status = row["status"]
else:
merged[row["name"]] = AdapterSpec(
name=row["name"],
base_model=row["base_model"],
cluster=row["cluster"],
version=row.get("version") or 1,
status=row.get("status") or "candidate",
)
return list(merged.values())
def active_adapter(self, base_model: str, cluster: str) -> AdapterSpec | None:
found = [
a for a in self.adapters()
if a.base_model == base_model and a.cluster == cluster and a.status == "active"
]
return max(found, key=lambda a: a.version) if found else None
def set_adapter(self, adapter: AdapterSpec) -> None:
self.store.set_adapter_state(
adapter.name,
base_model=adapter.base_model,
cluster=adapter.cluster,
version=adapter.version,
status=adapter.status,
)
def export(self) -> dict[str, Any]:
"""Effektiver Stand als YAML-fähiges Dict (für Rückführung nach Git)."""
slots = {}
for s in self.slots():
data = s.spec.model_dump(exclude={"name"})
data["base_model"] = s.base_model
if s.calibrated_context:
data["effective_context"] = s.calibrated_context
slots[s.name] = data
return {"slots": slots, "adapters": [a.model_dump() for a in self.adapters()]}
+276
View File
@@ -0,0 +1,276 @@
"""Router: Anfrage → Skill → Stufe → Slot/Modell (+Adapter), inkl. Token-Budget und Eskalation."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from pydantic import BaseModel, Field
from . import budget as budget_mod
from .embeddings import Embedder, Reranker, SkillIndex
from .problems import INFRA_SIGNALS, Signal
from .registry import Registry, Slot
from .skills import Skill, SkillLibrary
PromptVariant = Literal["full", "compact", "none"]
FORMAT_HINT_JSON = "Antworte ausschließlich mit gültigem JSON ohne weiteren Text."
class RouteDecision(BaseModel):
status: Literal["routed", "failed", "too_large"] = "routed"
stage: int = 0
slot: str | None = None
model: str | None = None
skill: str | None = None
skill_version: int | None = None
adapter: str | None = None
prompt_variant: PromptVariant = "none"
similarity: float | None = None
estimated_tokens: int | None = None
max_output_tokens: int | None = None
attempt: int = 0
tried_slots: list[str] = Field(default_factory=list)
reasons: list[str] = Field(default_factory=list)
signals: list[Signal] = Field(default_factory=list)
@dataclass
class RouterConfig:
top_k: int = 5
conflict_margin: float = 0.02
max_escalations: int = 3
default_max_output_tokens: int = 1024
def system_prompt(skill: Skill | None, variant: PromptVariant) -> str:
parts: list[str] = []
if skill is not None and variant != "none":
parts.append(skill.prompt(variant))
if skill is not None and skill.meta.output.format == "json":
parts.append(FORMAT_HINT_JSON)
return "\n\n".join(parts)
class Router:
def __init__(
self,
registry: Registry,
library: SkillLibrary,
embedder: Embedder,
reranker: Reranker | None = None,
tokenizer: budget_mod.Tokenizer = budget_mod.estimate_tokens,
config: RouterConfig | None = None,
):
self.registry = registry
self.library = library
self.embedder = embedder
self.reranker = reranker
self.tokenizer = tokenizer
self.config = config or RouterConfig()
self.index = SkillIndex(embedder)
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()})
# --- Routing ----------------------------------------------------------------------------
def route(self, text: str, hint_skill: str | None = None) -> RouteDecision:
reasons: list[str] = []
signals: list[Signal] = []
routable = {s.name for s in self.library.routable()}
skill: Skill | None = None
similarity: float | None = None
if hint_skill:
if hint_skill not in routable:
raise KeyError(f"Skill {hint_skill} unbekannt oder nicht routbar")
skill = self.library.get(hint_skill)
reasons.append(f"Skill per Hinweis: {hint_skill}")
else:
skill, similarity = self._match(text, routable, reasons, signals)
if skill is None:
return self._plan_strong(text, None, reasons, signals, similarity=similarity)
meta = skill.meta
if meta.status == "demoted" or meta.stage == 0:
reasons.append(f"Skill {skill.name} ist auf Stufe 0 ({meta.status})")
return self._plan_strong(text, skill, reasons, signals, similarity=similarity)
if meta.execution == "classifier" and meta.stage >= 3 and meta.classifier:
return self._decision(stage=3, skill=skill, similarity=similarity, reasons=reasons,
signals=signals)
if meta.execution == "program" and meta.stage >= 2 and meta.program:
return self._decision(stage=2, skill=skill, similarity=similarity, reasons=reasons,
signals=signals)
return self._plan_weak(text, skill, reasons, signals, similarity=similarity)
def _match(self, text: str, routable: set[str], reasons: list[str],
signals: list[Signal]) -> tuple[Skill | None, float | None]:
if not routable:
reasons.append("Keine routbaren Skills")
signals.append(Signal.NO_SKILL)
return None, None
qvec = self.embedder.embed([text])[0]
hits = self.index.search(qvec, k=self.config.top_k, only=routable)
if not hits:
signals.append(Signal.NO_SKILL)
return None, None
similarity_of = dict(hits)
ordered = hits
if self.reranker is not None and len(hits) > 1:
docs = [self.library.get(name).description for name, _ in hits]
scores = self.reranker.rerank(text, docs)
ordered = sorted(zip([h[0] for h in hits], scores, strict=True), key=lambda x: -x[1])
reasons.append("Reranking aktiv")
best_name = ordered[0][0]
best_sim = similarity_of[best_name]
skill = self.library.get(best_name)
if best_sim < skill.meta.threshold:
reasons.append(f"Beste Ähnlichkeit {best_sim:.3f} < Schwelle {skill.meta.threshold} ({best_name})")
signals.append(Signal.NO_SKILL)
return None, best_sim
if len(hits) > 1:
second_name, second_sim = hits[1] if hits[0][0] == best_name else hits[0]
if abs(best_sim - second_sim) <= self.config.conflict_margin:
signals.append(Signal.SKILL_CONFLICT)
reasons.append(f"Konflikt {best_name} ({best_sim:.3f}) vs. {second_name} ({second_sim:.3f})")
if self.reranker is None:
return None, best_sim
reasons.append(f"Skill {best_name} (Ähnlichkeit {best_sim:.3f})")
return skill, best_sim
# --- Planung ----------------------------------------------------------------------------
def _decision(self, **kw) -> RouteDecision:
skill: Skill | None = kw.pop("skill", None)
if skill is not None:
kw.setdefault("skill", skill.name)
kw.setdefault("skill_version", skill.meta.version)
kw.setdefault("max_output_tokens", skill.meta.max_output_tokens)
return RouteDecision(**kw)
def _fit(self, text: str, skill: Skill | None, slots: list[Slot]
) -> tuple[Slot, PromptVariant, budget_mod.Budget] | None:
max_out = skill.meta.max_output_tokens if skill else self.config.default_max_output_tokens
variants: list[PromptVariant] = ["full", "compact"] if skill else ["none"]
for slot in slots:
for variant in variants:
b = budget_mod.compute(system_prompt(skill, variant), text, max_out,
slot.effective_context, self.tokenizer)
if b.fits:
return slot, variant, b
return None
def _weak_slots(self, skill: Skill, exclude: set[str]) -> tuple[list[Slot], bool]:
names = [skill.meta.slot, *self._fallbacks(skill.meta.slot)]
slots = [self.registry.slot(n) for n in names if self.registry.has_slot(n) and n not in exclude]
primary_ok = bool(slots) and slots[0].name == skill.meta.slot and slots[0].available
return [s for s in slots if s.available], primary_ok
def _fallbacks(self, name: str) -> list[str]:
if not self.registry.has_slot(name):
return []
return list(self.registry.slot(name).spec.fallbacks)
def _plan_weak(self, text: str, skill: Skill, reasons: list[str], signals: list[Signal], *,
similarity: float | None, attempt: int = 0, tried: list[str] | None = None
) -> RouteDecision:
tried = list(tried or [])
slots, primary_ok = self._weak_slots(skill, set(tried))
if not primary_ok and skill.meta.slot not in tried:
signals.append(Signal.SLOT_UNAVAILABLE)
reasons.append(f"Slot {skill.meta.slot} nicht verfügbar")
if not slots:
reasons.append("Kein verfügbarer Slot für Stufe 1")
return self._plan_strong(text, skill, reasons, signals, similarity=similarity,
attempt=attempt, tried=tried)
fit = self._fit(text, skill, slots)
if fit is None:
signals.append(Signal.CONTEXT_EXCEEDED)
reasons.append("Token-Budget passt in keinen Stufe-1-Slot")
return self._plan_strong(text, skill, reasons, signals, similarity=similarity,
attempt=attempt, tried=tried)
slot, variant, b = fit
if variant == "compact":
signals.append(Signal.COMPACT_PROMPT)
if slot.name != slots[0].name:
signals.append(Signal.LARGER_SLOT)
reasons.append(f"Ausweichen auf {slot.name} wegen Kontextlänge")
model, adapter = slot.spec.litellm_model, None
if slot.spec.supports_lora and skill.meta.adapter == "auto":
found = self.registry.active_adapter(slot.base_model, skill.cluster)
if found:
model, adapter = found.name, found.name
reasons.append(f"Adapter {found.name}")
return self._decision(stage=1, slot=slot.name, model=model, adapter=adapter, skill=skill,
prompt_variant=variant, similarity=similarity,
estimated_tokens=b.prompt_tokens, attempt=attempt,
tried_slots=tried + [slot.name], reasons=reasons, signals=signals)
def _plan_strong(self, text: str, skill: Skill | None, reasons: list[str], signals: list[Signal], *,
similarity: float | None = None, attempt: int = 0, tried: list[str] | None = None
) -> RouteDecision:
tried = list(tried or [])
strong = [s for s in self.registry.slots()
if s.spec.kind == "chat" and s.spec.tier == 0 and s.name not in tried]
available = [s for s in strong if s.available] or strong
if not available:
reasons.append("Kein Stufe-0-Slot mehr übrig")
return self._decision(status="failed", stage=0, skill=skill, similarity=similarity,
attempt=attempt, tried_slots=tried, reasons=reasons, signals=signals)
fit = self._fit(text, skill, available)
if fit is None and skill is not None:
# notfalls ohne Skill-Text
fit = self._fit(text, None, available)
if fit is None:
signals.append(Signal.NEEDS_SPLIT)
reasons.append("Eingabe passt in keinen Slot – Aufgabe muss geteilt werden")
return self._decision(status="too_large", stage=0, skill=skill, similarity=similarity,
attempt=attempt, tried_slots=tried, reasons=reasons, signals=signals)
slot, variant, b = fit
return self._decision(stage=0, slot=slot.name, model=slot.spec.litellm_model, skill=skill,
prompt_variant=variant, similarity=similarity,
estimated_tokens=b.prompt_tokens, attempt=attempt,
tried_slots=tried + [slot.name], reasons=reasons, signals=signals)
# --- Eskalation -------------------------------------------------------------------------
def escalate(self, decision: RouteDecision, signal: Signal, text: str) -> RouteDecision:
attempt = decision.attempt + 1
reasons = [f"Eskalation {attempt} wegen {signal.value} (Stufe {decision.stage})"]
signals: list[Signal] = [signal]
skill = self.library.get(decision.skill) if decision.skill and self.library.has(decision.skill) else None
if attempt > self.config.max_escalations:
reasons.append("Maximale Eskalationen erreicht")
return self._decision(status="failed", stage=decision.stage, skill=skill, attempt=attempt,
tried_slots=decision.tried_slots, reasons=reasons, signals=signals)
infra = signal in INFRA_SIGNALS
if decision.stage == 0:
if infra:
return self._plan_strong(text, skill, reasons, signals, similarity=decision.similarity,
attempt=attempt, tried=decision.tried_slots)
reasons.append("Stufe 0 bereits erreicht")
return self._decision(status="failed", stage=0, skill=skill, attempt=attempt,
tried_slots=decision.tried_slots, reasons=reasons, signals=signals)
if decision.stage == 1 and skill is not None:
if infra:
return self._plan_weak(text, skill, reasons, signals, similarity=decision.similarity,
attempt=attempt, tried=decision.tried_slots)
return self._plan_strong(text, skill, reasons, signals, similarity=decision.similarity,
attempt=attempt)
# Stufe 2/3 → Stufe 1 (falls erlaubt) oder 0
if skill is not None and skill.meta.llm_fallback:
return self._plan_weak(text, skill, reasons, signals, similarity=decision.similarity,
attempt=attempt)
return self._plan_strong(text, skill, reasons, signals, similarity=decision.similarity,
attempt=attempt)
+157
View File
@@ -0,0 +1,157 @@
"""Die komplette Schleife: Route → Ausführung → Verifier → Eskalation → Fall/Präferenzpaar."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
from .cases import split_for
from .executor import ExecutionResult, Executor
from .problems import Signal
from .router import RouteDecision, Router
from .skills import SkillLibrary
from .store import Store, new_id
from .verifier import Verifier, VerifyResult
PREVENTIVE_EVENT_SIGNALS = {
Signal.CONTEXT_EXCEEDED, Signal.COMPACT_PROMPT, Signal.LARGER_SLOT, Signal.NEEDS_SPLIT,
Signal.SKILL_CONFLICT, Signal.SLOT_UNAVAILABLE,
}
class Attempt(BaseModel):
decision: RouteDecision
execution: ExecutionResult | None = None
verification: VerifyResult | None = None
class RunResult(BaseModel):
run_id: str
status: str # ok | failed | too_large
output: str | None = None
stage: int | None = None
skill: str | None = None
attempts: list[Attempt] = Field(default_factory=list)
@property
def escalations(self) -> int:
return max(0, len(self.attempts) - 1)
class Runner:
def __init__(self, router: Router, executor: Executor, verifier: Verifier, library: SkillLibrary,
store: Store, metrics: Any | None = None):
self.router = router
self.executor = executor
self.verifier = verifier
self.library = library
self.store = store
self.metrics = metrics
def run(self, text: str, hint_skill: str | None = None) -> RunResult:
run_id = new_id()
decision = self.router.route(text, hint_skill=hint_skill)
attempts: list[Attempt] = []
failed_weak: list[tuple[Attempt, ExecutionResult]] = []
while True:
self._events(decision)
attempt = Attempt(decision=decision)
attempts.append(attempt)
if decision.status != "routed":
return self._finish(run_id, decision.status, attempts, None)
execution = self.executor.execute(decision, text)
attempt.execution = execution
skill = self.library.get(decision.skill) if decision.skill else None
signal: Signal | None = None
if execution.signals:
signal = execution.signals[0]
for s in execution.signals:
self.store.add_event(s.value, subject=decision.slot or decision.skill)
else:
verification = self.verifier.verify(skill, text, execution.output)
attempt.verification = verification
if not verification.passed:
signal = Signal.VERIFIER_FAILED
if decision.similarity is not None and decision.stage >= 1:
self.store.add_event(Signal.MISROUTE_SUSPECTED.value, subject=decision.skill)
infra = signal in {Signal.ENDPOINT_ERROR, Signal.CONTEXT_OVERFLOW}
if not infra:
self._record_case(run_id, text, decision, attempt, skill)
if signal is None:
self._record_pairs(run_id, text, attempt, failed_weak, skill)
return self._finish(run_id, "ok", attempts, attempt)
if decision.stage == 1 and not infra and execution.output:
failed_weak.append((attempt, execution))
decision = self.router.escalate(decision, signal, text)
# --- Hilfen -----------------------------------------------------------------------------
def _events(self, decision: RouteDecision) -> None:
for s in decision.signals:
if s in PREVENTIVE_EVENT_SIGNALS:
self.store.add_event(s.value, subject=decision.slot or decision.skill)
def _record_case(self, run_id: str, text: str, decision: RouteDecision, attempt: Attempt,
skill: Any) -> None:
execution = attempt.execution
assert execution is not None
verification = attempt.verification
if verification is None:
passed: bool | None = False
elif verification.inconclusive:
passed = None # nicht unabhängig geprüft → nicht ins Training
else:
passed = verification.passed
self.store.add_case(
run_id=run_id,
input=text,
skill=decision.skill,
skill_version=decision.skill_version,
cluster=skill.cluster if skill else None,
stage=decision.stage,
slot=decision.slot,
model=decision.model,
adapter=decision.adapter,
messages=execution.messages or [{"role": "user", "content": text}],
output=execution.output,
passed=passed,
signals=[s.value for s in execution.signals],
prompt_tokens=decision.estimated_tokens,
split=split_for(text),
origin="route",
)
def _record_pairs(self, run_id: str, text: str, winner: Attempt,
failed: list[tuple[Attempt, ExecutionResult]], skill: Any) -> None:
if skill is None or not failed or winner.verification is None or winner.verification.inconclusive:
return
for _loser, execution in failed:
# Prompt im Format der schwachen Stufe: genau das soll das schwache Modell lernen
prompt = execution.messages or [{"role": "user", "content": text}]
self.store.add_preference_pair(
run_id=run_id,
skill=skill.name,
cluster=skill.cluster,
prompt=prompt,
chosen=winner.execution.output if winner.execution else "",
rejected=execution.output,
split=split_for(text),
)
def _finish(self, run_id: str, status: str, attempts: list[Attempt], last: Attempt | None) -> RunResult:
result = RunResult(
run_id=run_id,
status=status,
output=last.execution.output if last and last.execution else None,
stage=last.decision.stage if last else attempts[-1].decision.stage,
skill=attempts[0].decision.skill,
attempts=attempts,
)
if self.metrics is not None:
self.metrics.observe_run(result)
return result
+64
View File
@@ -0,0 +1,64 @@
"""Konfiguration aus Umgebungsvariablen."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
def _bool(value: str | None, default: bool) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "ja", "on"}
@dataclass
class Settings:
api_token: str = ""
db_url: str = "sqlite:///data/knaster.db"
site_dir: Path = field(default_factory=lambda: REPO_ROOT / "sites" / "knaster")
skills_dir: Path = field(default_factory=lambda: REPO_ROOT / "skills")
config_dir: Path = field(default_factory=lambda: REPO_ROOT / "config")
dry_run: bool = True
litellm_base_url: str = "http://litellm:4000"
litellm_api_key: str = ""
embed_model: str = "embed-default"
rerank_url: str = ""
rerank_model: str = "rerank-default"
portainer_host: str = ""
portainer_key: str = ""
proxmox_url: str = ""
proxmox_token_id: str = ""
proxmox_token_secret: str = ""
notify_webhook_url: str = ""
job_webhook_url: str = ""
request_timeout: float = 120.0
@classmethod
def from_env(cls) -> Settings:
env = os.environ
base = cls()
return cls(
api_token=env.get("KNASTER_API_TOKEN", base.api_token),
db_url=env.get("KNASTER_DB_URL", base.db_url),
site_dir=Path(env.get("KNASTER_SITE_DIR", base.site_dir)),
skills_dir=Path(env.get("KNASTER_SKILLS_DIR", base.skills_dir)),
config_dir=Path(env.get("KNASTER_CONFIG_DIR", base.config_dir)),
dry_run=_bool(env.get("KNASTER_DRY_RUN"), True),
litellm_base_url=env.get("LITELLM_BASE_URL", base.litellm_base_url),
litellm_api_key=env.get("LITELLM_API_KEY", base.litellm_api_key),
embed_model=env.get("KNASTER_EMBED_MODEL", base.embed_model),
rerank_url=env.get("RERANK_URL", base.rerank_url),
rerank_model=env.get("KNASTER_RERANK_MODEL", base.rerank_model),
portainer_host=env.get("PORTAINER_HOST", ""),
portainer_key=env.get("PORTAINER_KEY", ""),
proxmox_url=env.get("PROXMOX_URL", ""),
proxmox_token_id=env.get("PROXMOX_TOKEN_ID", ""),
proxmox_token_secret=env.get("PROXMOX_TOKEN_SECRET", ""),
notify_webhook_url=env.get("NOTIFY_WEBHOOK_URL", ""),
job_webhook_url=env.get("KNASTER_JOB_WEBHOOK_URL", ""),
request_timeout=float(env.get("KNASTER_REQUEST_TIMEOUT", base.request_timeout)),
)
+161
View File
@@ -0,0 +1,161 @@
"""SKILL.md-Bibliothek (agentskills-/Hermes-kompatibel, Zusatzfelder unter metadata.knaster)."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Literal
import yaml
from pydantic import BaseModel, Field
from .store import Store
_FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
_OPTIONAL_HEADING = re.compile(r"^##\s+Beispiele\b", re.IGNORECASE)
SkillStatus = Literal["draft", "active", "demoted", "retired"]
class OutputSpec(BaseModel):
format: Literal["text", "json"] = "text"
schema_: dict[str, Any] | None = Field(default=None, alias="schema")
model_config = {"populate_by_name": True}
class KnasterMeta(BaseModel):
version: int = 1
status: SkillStatus = "draft"
stage: int = Field(default=0, ge=0, le=3)
execution: Literal["llm", "program", "classifier"] = "llm"
llm_fallback: bool = True
slot: str = "tier1-general"
cluster: str | None = None
adapter: Literal["auto", "none"] = "auto"
threshold: float = 0.80
triggers: list[str] = Field(default_factory=list)
output: OutputSpec = Field(default_factory=OutputSpec)
verifier: list[dict[str, Any]] = Field(default_factory=list)
max_output_tokens: int = 1024
program: str | None = None
classifier: str | None = None
min_confidence: float = 0.6
class Skill(BaseModel):
name: str
description: str
body: str
meta: KnasterMeta
path: str | None = None
@property
def cluster(self) -> str:
return self.meta.cluster or self.name
@property
def routable(self) -> bool:
return self.meta.status in {"active", "demoted"}
def prompt(self, variant: Literal["full", "compact"]) -> str:
if variant == "full":
return self.body.strip()
return compact_body(self.body)
def index_texts(self) -> list[str]:
return [self.description, *self.meta.triggers]
def compact_body(body: str) -> str:
"""Entfernt '## Beispiele…'-Abschnitte bis zur nächsten Überschrift gleicher/höherer Ebene."""
out: list[str] = []
skipping = False
for line in body.splitlines():
if _OPTIONAL_HEADING.match(line):
skipping = True
continue
if skipping and re.match(r"^#{1,2}\s", line):
skipping = False
if not skipping:
out.append(line)
return "\n".join(out).strip()
def parse_skill(text: str, path: str | None = None) -> Skill:
match = _FRONTMATTER.match(text)
if not match:
raise ValueError(f"{path or 'Skill'}: Frontmatter fehlt")
front = yaml.safe_load(match.group(1)) or {}
for key in ("name", "description"):
if not front.get(key):
raise ValueError(f"{path or 'Skill'}: Feld '{key}' fehlt")
knaster = ((front.get("metadata") or {}).get("knaster")) or {}
return Skill(
name=str(front["name"]),
description=str(front["description"]).strip(),
body=match.group(2),
meta=KnasterMeta(**knaster),
path=path,
)
class SkillLibrary:
"""Lädt Skills aus einem Verzeichnis (<dir>/<name>/SKILL.md) und überlagert Laufzeitzustand."""
def __init__(self, skills: list[Skill], store: Store | None = None):
self._skills = {s.name: s for s in skills}
if len(self._skills) != len(skills):
raise ValueError("Doppelte Skill-Namen")
self.store = store
@classmethod
def from_dir(cls, directory: str | Path, store: Store | None = None) -> SkillLibrary:
skills = []
for file in sorted(Path(directory).glob("*/SKILL.md")):
skills.append(parse_skill(file.read_text(encoding="utf-8"), str(file)))
return cls(skills, store)
def _states(self) -> dict[str, dict[str, Any]]:
if not self.store:
return {}
return {row["name"]: row for row in self.store.all_skill_states()}
@staticmethod
def _apply_state(skill: Skill, states: dict[str, dict[str, Any]]) -> Skill:
row = states.get(skill.name)
if not row:
return skill
meta = skill.meta.model_copy()
if row.get("status"):
meta.status = row["status"]
if row.get("stage") is not None:
meta.stage = row["stage"]
return skill.model_copy(update={"meta": meta})
def get(self, name: str) -> Skill:
return self._apply_state(self._skills[name], self._states())
def has(self, name: str) -> bool:
return name in self._skills
def all(self) -> list[Skill]:
states = self._states()
return [self._apply_state(s, states) for s in self._skills.values()]
def routable(self) -> list[Skill]:
return [s for s in self.all() if s.routable]
def set_state(self, name: str, *, status: str | None = None, stage: int | None = None,
reason: str = "") -> None:
if name not in self._skills:
raise KeyError(name)
if not self.store:
raise RuntimeError("SkillLibrary ohne Store kann keinen Zustand speichern")
current = self.get(name)
self.store.set_skill_state(
name,
status=status or current.meta.status,
stage=current.meta.stage if stage is None else stage,
reason=reason,
)
+348
View File
@@ -0,0 +1,348 @@
"""Persistenz (SQLAlchemy Core). SQLite für Tests/kleine Setups, Postgres im Betrieb."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
Column,
Float,
Integer,
MetaData,
String,
Table,
Text,
create_engine,
delete,
func,
insert,
select,
update,
)
from sqlalchemy.engine import Engine
from sqlalchemy.pool import StaticPool
from .util import now
metadata = MetaData()
cases = Table(
"cases",
metadata,
Column("id", String(36), primary_key=True),
Column("ts", Float, nullable=False, index=True),
Column("run_id", String(36), index=True),
Column("input", Text, nullable=False),
Column("skill", String(200), index=True),
Column("skill_version", Integer),
Column("cluster", String(200), index=True),
Column("stage", Integer, nullable=False),
Column("slot", String(100)),
Column("model", String(200)),
Column("adapter", String(200)),
Column("messages", JSON),
Column("output", Text),
Column("passed", Boolean),
Column("signals", JSON),
Column("prompt_tokens", Integer),
Column("split", String(10), nullable=False),
Column("origin", String(20), nullable=False),
)
preference_pairs = Table(
"preference_pairs",
metadata,
Column("id", String(36), primary_key=True),
Column("ts", Float, nullable=False),
Column("run_id", String(36)),
Column("skill", String(200), index=True),
Column("cluster", String(200)),
Column("prompt", JSON),
Column("chosen", Text),
Column("rejected", Text),
Column("split", String(10)),
)
proposals = Table(
"proposals",
metadata,
Column("seq", Integer, primary_key=True, autoincrement=True),
Column("kind", String(50), nullable=False),
Column("params", JSON),
Column("summary", Text),
Column("reason", Text),
Column("risk", String(10)),
Column("state", String(20), nullable=False, index=True),
Column("source", String(20)),
Column("dedup_key", String(300), index=True),
Column("created_at", Float),
Column("decided_at", Float),
Column("decided_by", String(100)),
Column("result", JSON),
)
slot_state = Table(
"slot_state",
metadata,
Column("slot", String(100), primary_key=True),
Column("status", String(20)),
Column("effective_context", Integer),
Column("base_model", String(200)),
Column("reason", Text),
Column("set_by", String(50)),
Column("updated_at", Float),
)
adapter_state = Table(
"adapter_state",
metadata,
Column("name", String(200), primary_key=True),
Column("base_model", String(200)),
Column("cluster", String(200)),
Column("version", Integer),
Column("status", String(20)),
Column("updated_at", Float),
)
skill_state = Table(
"skill_state",
metadata,
Column("name", String(200), primary_key=True),
Column("status", String(20)),
Column("stage", Integer),
Column("reason", Text),
Column("updated_at", Float),
)
candidates = Table(
"candidates",
metadata,
Column("id", String(36), primary_key=True),
Column("kind", String(30)),
Column("slot", String(100)),
Column("target", JSON),
Column("baseline", JSON),
Column("state", String(30), index=True),
Column("metrics", JSON),
Column("history", JSON),
Column("snapshot", JSON),
Column("created_at", Float),
Column("updated_at", Float),
)
healer_state = Table(
"healer_state",
metadata,
Column("key", String(300), primary_key=True),
Column("data", JSON),
Column("updated_at", Float),
)
events = Table(
"events",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("ts", Float, nullable=False, index=True),
Column("kind", String(50), nullable=False, index=True),
Column("subject", String(200), index=True),
Column("data", JSON),
)
def new_id() -> str:
return str(uuid.uuid4())
class Store:
"""Dünne Repository-Schicht. Jede Methode öffnet ihre eigene Transaktion."""
def __init__(self, url: str):
kwargs: dict[str, Any] = {}
if url.startswith("sqlite"):
kwargs["connect_args"] = {"check_same_thread": False}
if url in {"sqlite://", "sqlite:///:memory:"}:
kwargs["poolclass"] = StaticPool
self.engine: Engine = create_engine(url, future=True, **kwargs)
metadata.create_all(self.engine)
# --- Fälle -----------------------------------------------------------------------------
def add_case(self, **row: Any) -> str:
row.setdefault("id", new_id())
row.setdefault("ts", now())
with self.engine.begin() as conn:
conn.execute(insert(cases).values(**row))
return row["id"]
def list_cases(
self,
*,
skill: str | None = None,
cluster: str | None = None,
min_stage: int | None = None,
stage: int | None = None,
split: str | None = None,
passed: bool | None = None,
since: float | None = None,
limit: int | None = None,
) -> list[dict[str, Any]]:
q = select(cases).order_by(cases.c.ts.desc())
if skill is not None:
q = q.where(cases.c.skill == skill)
if cluster is not None:
q = q.where(cases.c.cluster == cluster)
if min_stage is not None:
q = q.where(cases.c.stage >= min_stage)
if stage is not None:
q = q.where(cases.c.stage == stage)
if split is not None:
q = q.where(cases.c.split == split)
if passed is not None:
q = q.where(cases.c.passed == passed)
if since is not None:
q = q.where(cases.c.ts >= since)
if limit is not None:
q = q.limit(limit)
with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(q)]
def add_preference_pair(self, **row: Any) -> str:
row.setdefault("id", new_id())
row.setdefault("ts", now())
with self.engine.begin() as conn:
conn.execute(insert(preference_pairs).values(**row))
return row["id"]
def list_preference_pairs(self, *, cluster: str | None = None) -> list[dict[str, Any]]:
q = select(preference_pairs).order_by(preference_pairs.c.ts)
if cluster is not None:
q = q.where(preference_pairs.c.cluster == cluster)
with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(q)]
# --- Ereignisse ------------------------------------------------------------------------
def add_event(self, kind: str, subject: str | None = None, data: dict | None = None) -> None:
with self.engine.begin() as conn:
conn.execute(insert(events).values(ts=now(), kind=kind, subject=subject, data=data or {}))
def count_events(self, kind: str, *, subject: str | None = None, since: float | None = None) -> int:
q = select(func.count()).select_from(events).where(events.c.kind == kind)
if subject is not None:
q = q.where(events.c.subject == subject)
if since is not None:
q = q.where(events.c.ts >= since)
with self.engine.connect() as conn:
return int(conn.execute(q).scalar_one())
def event_subjects(self, kind: str, *, since: float | None = None) -> dict[str, int]:
q = select(events.c.subject, func.count()).where(events.c.kind == kind).group_by(events.c.subject)
if since is not None:
q = q.where(events.c.ts >= since)
with self.engine.connect() as conn:
return {r[0]: int(r[1]) for r in conn.execute(q) if r[0] is not None}
# --- generische Zustandstabellen -----------------------------------------------------
def _upsert(self, table: Table, key_col: str, key: str, values: dict[str, Any]) -> None:
values = {**values, "updated_at": now()}
with self.engine.begin() as conn:
exists = conn.execute(select(table).where(table.c[key_col] == key)).first()
if exists:
conn.execute(update(table).where(table.c[key_col] == key).values(**values))
else:
conn.execute(insert(table).values(**{key_col: key, **values}))
def _get(self, table: Table, key_col: str, key: str) -> dict[str, Any] | None:
with self.engine.connect() as conn:
row = conn.execute(select(table).where(table.c[key_col] == key)).first()
return dict(row._mapping) if row else None
def _all(self, table: Table) -> list[dict[str, Any]]:
with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(select(table))]
def set_slot_state(self, slot: str, **values: Any) -> None:
self._upsert(slot_state, "slot", slot, values)
def get_slot_state(self, slot: str) -> dict[str, Any] | None:
return self._get(slot_state, "slot", slot)
def all_slot_states(self) -> list[dict[str, Any]]:
return self._all(slot_state)
def set_adapter_state(self, name: str, **values: Any) -> None:
self._upsert(adapter_state, "name", name, values)
def all_adapter_states(self) -> list[dict[str, Any]]:
return self._all(adapter_state)
def set_skill_state(self, name: str, **values: Any) -> None:
self._upsert(skill_state, "name", name, values)
def all_skill_states(self) -> list[dict[str, Any]]:
return self._all(skill_state)
# --- Vorschläge ------------------------------------------------------------------------
def add_proposal(self, **row: Any) -> int:
row.setdefault("created_at", now())
row.setdefault("state", "open")
with self.engine.begin() as conn:
res = conn.execute(insert(proposals).values(**row))
return int(res.inserted_primary_key[0])
def get_proposal(self, seq: int) -> dict[str, Any] | None:
return self._get(proposals, "seq", seq) # type: ignore[arg-type]
def update_proposal(self, seq: int, **values: Any) -> None:
with self.engine.begin() as conn:
conn.execute(update(proposals).where(proposals.c.seq == seq).values(**values))
def list_proposals(self, *, state: str | None = None) -> list[dict[str, Any]]:
q = select(proposals).order_by(proposals.c.seq)
if state is not None:
q = q.where(proposals.c.state == state)
with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(q)]
def open_proposal_with_key(self, dedup_key: str) -> dict[str, Any] | None:
q = select(proposals).where(proposals.c.dedup_key == dedup_key, proposals.c.state == "open")
with self.engine.connect() as conn:
row = conn.execute(q).first()
return dict(row._mapping) if row else None
# --- Kandidaten ------------------------------------------------------------------------
def add_candidate(self, **row: Any) -> str:
row.setdefault("id", new_id())
row.setdefault("created_at", now())
row.setdefault("updated_at", row["created_at"])
with self.engine.begin() as conn:
conn.execute(insert(candidates).values(**row))
return row["id"]
def get_candidate(self, cid: str) -> dict[str, Any] | None:
return self._get(candidates, "id", cid)
def update_candidate(self, cid: str, **values: Any) -> None:
values["updated_at"] = now()
with self.engine.begin() as conn:
conn.execute(update(candidates).where(candidates.c.id == cid).values(**values))
def list_candidates(self, *, state: str | None = None) -> list[dict[str, Any]]:
q = select(candidates).order_by(candidates.c.created_at)
if state is not None:
q = q.where(candidates.c.state == state)
with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(q)]
# --- Healer-Zustand --------------------------------------------------------------------
def get_healer_state(self, key: str) -> dict[str, Any] | None:
row = self._get(healer_state, "key", key)
return row["data"] if row else None
def set_healer_state(self, key: str, data: dict[str, Any]) -> None:
self._upsert(healer_state, "key", key, {"data": data})
def delete_healer_state(self, key: str) -> None:
with self.engine.begin() as conn:
conn.execute(delete(healer_state).where(healer_state.c.key == key))
+56
View File
@@ -0,0 +1,56 @@
"""Kleine Hilfsfunktionen ohne Fachlogik."""
from __future__ import annotations
import re
import time
from pathlib import Path
from typing import Any
import yaml
_DURATION = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([smhd]?)\s*$")
_FACTORS = {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400}
_PLACEHOLDER = re.compile(r"\{([a-zA-Z0-9_]+)\}")
def now() -> float:
return time.time()
def load_yaml(path: str | Path) -> Any:
with open(path, encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
def parse_duration(value: str | int | float | None) -> float:
"""'5m' -> 300.0; Zahlen gelten als Sekunden."""
if value is None:
return 0.0
if isinstance(value, int | float):
return float(value)
match = _DURATION.match(value)
if not match:
raise ValueError(f"Ungültige Dauer: {value!r}")
return float(match.group(1)) * _FACTORS[match.group(2)]
class MissingLabel(KeyError):
pass
def fill_template(value: Any, labels: dict[str, str]) -> Any:
"""Ersetzt {label} rekursiv; fehlende Labels lösen MissingLabel aus."""
if isinstance(value, str):
def repl(m: re.Match[str]) -> str:
key = m.group(1)
if key not in labels:
raise MissingLabel(key)
return str(labels[key])
return _PLACEHOLDER.sub(repl, value)
if isinstance(value, dict):
return {k: fill_template(v, labels) for k, v in value.items()}
if isinstance(value, list):
return [fill_template(v, labels) for v in value]
return value
+116
View File
@@ -0,0 +1,116 @@
"""Verifier: deterministische Prüfungen + optionaler LLM-Judge."""
from __future__ import annotations
import re
from typing import Any
import jsonschema
from pydantic import BaseModel, Field
from .llm import ChatClient, LLMError
from .problems import extract_json
from .registry import Registry
from .skills import Skill
JUDGE_SYSTEM = (
"Du bist ein strenger Prüfer. Bewerte, ob die Antwort die Aufgabe gemäß den Kriterien erfüllt. "
"Antworte mit genau einer Zeile: 'PASS' oder 'FAIL: <kurzer Grund>'."
)
class CheckResult(BaseModel):
type: str
passed: bool | None # None = nicht entscheidbar (z. B. Judge nicht erreichbar)
detail: str = ""
class VerifyResult(BaseModel):
passed: bool
inconclusive: bool = False
checks: list[CheckResult] = Field(default_factory=list)
@property
def failed_checks(self) -> list[CheckResult]:
return [c for c in self.checks if c.passed is False]
class Verifier:
def __init__(self, registry: Registry | None = None, chat: ChatClient | None = None,
default_judge_slot: str = "verifier-small"):
self.registry = registry
self.chat = chat
self.default_judge_slot = default_judge_slot
def verify(self, skill: Skill | None, text: str, output: str) -> VerifyResult:
if skill is None:
return VerifyResult(passed=True, checks=[])
checks: list[CheckResult] = []
specs = list(skill.meta.verifier)
schema = skill.meta.output.schema_
if skill.meta.output.format == "json":
checks.append(self._json(output, schema))
specs = [s for s in specs if s.get("type") != "json_schema"]
for spec in specs:
checks.append(self._run(spec, skill, text, output))
conclusive = [c for c in checks if c.passed is not None]
return VerifyResult(
passed=all(c.passed for c in conclusive),
inconclusive=len(conclusive) != len(checks),
checks=checks,
)
def _run(self, spec: dict[str, Any], skill: Skill, text: str, output: str) -> CheckResult:
kind = spec.get("type")
if kind == "json_schema":
return self._json(output, skill.meta.output.schema_)
if kind == "regex":
ok = re.search(spec["pattern"], output, re.MULTILINE) is not None
return CheckResult(type=kind, passed=ok, detail="" if ok else f"Muster fehlt: {spec['pattern']}")
if kind == "contains":
ok = spec["value"] in output
return CheckResult(type=kind, passed=ok, detail="" if ok else f"fehlt: {spec['value']}")
if kind == "not_contains":
ok = spec["value"] not in output
return CheckResult(type=kind, passed=ok, detail="" if ok else f"enthält: {spec['value']}")
if kind == "max_chars":
ok = len(output) <= int(spec["value"])
return CheckResult(type=kind, passed=ok, detail=f"{len(output)} Zeichen")
if kind == "judge":
return self._judge(spec, text, output)
return CheckResult(type=str(kind), passed=False, detail="Unbekannter Prüftyp")
@staticmethod
def _json(output: str, schema: dict[str, Any] | None) -> CheckResult:
try:
data = extract_json(output)
except ValueError as exc:
return CheckResult(type="json", passed=False, detail=f"kein gültiges JSON: {exc}")
if schema:
try:
jsonschema.validate(data, schema)
except jsonschema.ValidationError as exc:
return CheckResult(type="json_schema", passed=False, detail=exc.message)
return CheckResult(type="json_schema" if schema else "json", passed=True)
def _judge(self, spec: dict[str, Any], text: str, output: str) -> CheckResult:
if self.chat is None or self.registry is None:
return CheckResult(type="judge", passed=None, detail="kein Judge konfiguriert")
slot_name = spec.get("slot", self.default_judge_slot)
slot = self.registry.slot(slot_name)
if not slot.available:
return CheckResult(type="judge", passed=None, detail=f"Judge-Slot {slot_name} nicht verfügbar")
user = f"Aufgabe:\n{text}\n\nKriterien:\n{spec.get('rubric', 'Korrekt und vollständig.')}\n\nAntwort:\n{output}"
try:
res = self.chat.chat(slot.spec.litellm_model,
[{"role": "system", "content": JUDGE_SYSTEM}, {"role": "user", "content": user}],
max_tokens=60, temperature=0)
except LLMError as exc:
return CheckResult(type="judge", passed=None, detail=f"Judge-Fehler: {exc}")
verdict = res.content.strip()
first = verdict.split(maxsplit=1)[0].upper().rstrip(":") if verdict else ""
if first == "PASS":
return CheckResult(type="judge", passed=True)
if first == "FAIL":
return CheckResult(type="judge", passed=False, detail=verdict[:200])
return CheckResult(type="judge", passed=None, detail=f"Unklare Judge-Antwort: {verdict[:100]}")