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
View File
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
import textwrap
from collections.abc import Callable
from pathlib import Path
import pytest
from knaster.embeddings import HashingEmbedder
from knaster.llm import ChatResult
from knaster.registry import Registry
from knaster.skills import SkillLibrary
from knaster.store import Store
REGISTRY = {
"slots": {
"tier0-strong": {
"tier": 0, "device": "external", "engine": "openrouter", "litellm_model": "tier0-strong",
"base_model": "strong-x", "max_context": 128000, "effective_context": 100000,
},
"tier1-general": {
"tier": 1, "engine": "vllm", "node": "gpu-main", "litellm_model": "tier1-general",
"base_model": "base-14b", "max_context": 8192, "effective_context": 2000,
"supports_lora": True, "fallbacks": ["tier1-flex"],
},
"tier1-flex": {
"tier": 1, "engine": "vllm", "node": "gpu-flex", "litellm_model": "tier1-flex",
"base_model": "base-14b", "max_context": 32768, "effective_context": 16000,
"supports_lora": True,
},
"verifier-small": {
"tier": 1, "engine": "llamacpp", "node": "gpu-small", "litellm_model": "verifier-small",
"base_model": "small-4b", "max_context": 8192,
},
"embed-default": {
"tier": 1, "engine": "llamacpp", "node": "gpu-embed", "litellm_model": "embed-default",
"base_model": "embed-0.6b", "max_context": 8192, "kind": "embedding",
},
},
"adapters": [
{"name": "rechnungen-v1", "base_model": "base-14b", "cluster": "rechnungen", "version": 1},
],
}
SKILLS = {
"rechnung-extrahieren": """\
---
name: rechnung-extrahieren
description: Rechnung auslesen und Rechnungsnummer Betrag Datum als JSON extrahieren
metadata:
knaster:
status: active
stage: 1
cluster: rechnungen
threshold: 0.3
triggers: ["Rechnungsdaten aus einer Rechnung extrahieren"]
max_output_tokens: 300
output:
format: json
schema:
type: object
required: [nummer, betrag]
properties:
nummer: {type: string}
betrag: {type: number}
---
# Rechnung extrahieren
Lies Nummer und Betrag aus.
## Beispiele
Eingabe: Rechnung 7 über 10 EUR -> {"nummer": "7", "betrag": 10}
""",
"ticket-kategorisieren": """\
---
name: ticket-kategorisieren
description: Support Ticket einer Kategorie zuordnen Drucker Netzwerk Passwort
metadata:
knaster:
status: active
stage: 3
execution: classifier
classifier: ticket-kat
threshold: 0.3
min_confidence: 0.6
---
Ordne das Ticket einer Kategorie zu: drucker, netzwerk, passwort.
""",
"text-gross": """\
---
name: text-gross
description: Text in Großbuchstaben umwandeln uppercase
metadata:
knaster:
status: active
stage: 2
execution: program
program: echo-upper
threshold: 0.3
---
Wandle den Text in Großbuchstaben um.
""",
"entwurf": """\
---
name: entwurf
description: Gedicht über Rechnung schreiben
metadata:
knaster:
status: draft
---
Entwurf.
""",
"vertrag-pruefen": """\
---
name: vertrag-pruefen
description: Vertrag prüfen Kündigungsfrist Laufzeit Klauseln
metadata:
knaster:
status: demoted
stage: 1
threshold: 0.3
verifier:
- {type: contains, value: "Kündigungsfrist"}
---
Prüfe den Vertrag.
""",
}
@pytest.fixture
def store() -> Store:
return Store("sqlite://")
@pytest.fixture
def registry(store: Store) -> Registry:
return Registry.from_dict(REGISTRY, store)
@pytest.fixture
def skills_dir(tmp_path: Path) -> Path:
for name, text in SKILLS.items():
d = tmp_path / "skills" / name
d.mkdir(parents=True)
(d / "SKILL.md").write_text(textwrap.dedent(text), encoding="utf-8")
return tmp_path / "skills"
@pytest.fixture
def library(skills_dir: Path, store: Store) -> SkillLibrary:
return SkillLibrary.from_dir(skills_dir, store)
@pytest.fixture
def embedder() -> HashingEmbedder:
return HashingEmbedder()
class FakeChat:
"""Antworten pro Modellname; ein Handler darf ChatResult liefern oder eine Exception werfen."""
def __init__(self, handlers: dict[str, Callable[[list[dict[str, str]]], ChatResult | str]] | None = None):
self.handlers = handlers or {}
self.calls: list[tuple[str, list[dict[str, str]]]] = []
def chat(self, model, messages, *, max_tokens=None, temperature=None):
self.calls.append((model, messages))
handler = self.handlers.get(model)
if handler is None:
return ChatResult(content="ok", finish_reason="stop")
result = handler(messages)
if isinstance(result, str):
return ChatResult(content=result, finish_reason="stop")
return result
@pytest.fixture
def fake_chat() -> FakeChat:
return FakeChat()
+222
View File
@@ -0,0 +1,222 @@
import json
import pytest
from knaster.calibration import effective_context
from knaster.cases import export_preferences, export_sft, split_for
from knaster.classifier import EmbeddingClassifier
from knaster.executor import Executor
from knaster.llm import ChatResult, ContextOverflowError, EndpointError
from knaster.problems import Signal, extract_json, is_degenerate, repetition_ratio
from knaster.router import Router
from knaster.runner import Runner
from knaster.verifier import Verifier
from .test_router import RECHNUNG
GOOD = json.dumps({"nummer": "4711", "betrag": 99})
@pytest.fixture
def classifiers(embedder):
examples = []
for label, texts in {
"drucker": ["Drucker druckt nicht", "Drucker Papierstau", "Drucker offline"],
"netzwerk": ["WLAN geht nicht", "Netzwerk langsam", "kein Internet"],
}.items():
examples += [(v, label) for v in embedder.embed(texts)]
return {"ticket-kat": EmbeddingClassifier.train(examples)}
@pytest.fixture
def make_runner(registry, library, embedder, store, fake_chat, classifiers):
def make(**handlers):
fake_chat.handlers.update(handlers)
router = Router(registry, library, embedder)
executor = Executor(library, fake_chat, embedder, classifiers)
verifier = Verifier(registry, fake_chat)
return Runner(router, executor, verifier, library, store)
return make
def test_weak_success_records_case(make_runner, store):
runner = make_runner(**{"rechnungen-v1": lambda m: GOOD})
res = runner.run(RECHNUNG)
assert res.status == "ok" and res.stage == 1 and res.escalations == 0
cases = store.list_cases()
assert len(cases) == 1 and cases[0]["passed"] is True and cases[0]["cluster"] == "rechnungen"
assert cases[0]["messages"][0]["role"] == "system"
def test_schema_failure_escalates_and_creates_pair(make_runner, store):
runner = make_runner(**{
"rechnungen-v1": lambda m: json.dumps({"nummer": "4711"}), # betrag fehlt
"tier0-strong": lambda m: GOOD,
})
res = runner.run(RECHNUNG)
assert res.status == "ok" and res.stage == 0 and res.escalations == 1
pairs = store.list_preference_pairs()
assert len(pairs) == 1
assert pairs[0]["chosen"] == GOOD and "betrag" not in pairs[0]["rejected"]
assert {c["stage"]: c["passed"] for c in store.list_cases()} == {1: False, 0: True}
def test_invalid_json_signal(make_runner, store):
runner = make_runner(**{"rechnungen-v1": lambda m: "kein json", "tier0-strong": lambda m: GOOD})
res = runner.run(RECHNUNG)
assert Signal.INVALID_FORMAT in res.attempts[0].execution.signals
assert res.stage == 0
assert store.count_events("invalid_format") == 1
def test_endpoint_error_uses_fallback_slot_without_case(make_runner, store):
def boom(m):
raise EndpointError("down")
runner = make_runner(**{"rechnungen-v1": boom})
# Fallback-Slot tier1-flex hat dieselbe Basis → gleicher Adapter-Name; daher Fehler nur einmal
calls = {"n": 0}
def flaky(m):
calls["n"] += 1
if calls["n"] == 1:
raise EndpointError("down")
return GOOD
runner.executor.chat.handlers["rechnungen-v1"] = flaky
res = runner.run(RECHNUNG)
assert res.status == "ok"
assert [a.decision.slot for a in res.attempts] == ["tier1-general", "tier1-flex"]
assert len(store.list_cases()) == 1 # Infrastrukturfehler erzeugt keinen Fall
assert store.list_preference_pairs() == []
def test_context_overflow_detected(make_runner):
def overflow(m):
raise ContextOverflowError("maximum context length")
runner = make_runner(**{"rechnungen-v1": overflow, "tier0-strong": lambda m: GOOD})
res = runner.run(RECHNUNG)
assert res.attempts[0].execution.signals == [Signal.CONTEXT_OVERFLOW]
assert res.status == "ok"
def test_truncated_and_degenerate(make_runner):
loop = " ".join(["immer wieder das gleiche"] * 30)
runner = make_runner(**{
"rechnungen-v1": lambda m: ChatResult(content=loop, finish_reason="length"),
"tier0-strong": lambda m: GOOD,
})
res = runner.run(RECHNUNG)
sig = res.attempts[0].execution.signals
assert Signal.OUTPUT_TRUNCATED in sig and Signal.DEGENERATE_OUTPUT in sig
def test_total_failure(make_runner):
runner = make_runner(**{"rechnungen-v1": lambda m: "x", "tier0-strong": lambda m: "y"})
res = runner.run(RECHNUNG)
assert res.status == "failed"
def test_classifier_stage(make_runner):
runner = make_runner()
res = runner.run("Ticket: Drucker druckt nicht Papierstau")
assert res.status == "ok" and res.stage == 3 and res.output == "drucker"
def test_classifier_low_confidence_escalates(make_runner):
runner = make_runner(**{"tier1-general": lambda m: "drucker"})
res = runner.run("Ticket einer Kategorie zuordnen: Monitor flackert")
assert res.attempts[0].execution.signals == [Signal.CLASSIFIER_LOW_CONFIDENCE]
assert res.attempts[1].decision.stage == 1
def test_program_stage(make_runner):
res = make_runner().run("Text in Großbuchstaben: hallo welt")
assert res.output == "TEXT IN GROSSBUCHSTABEN: HALLO WELT"
def test_judge_check(registry, library, fake_chat):
skill = library.get("vertrag-pruefen").model_copy(deep=True)
skill.meta.verifier = [{"type": "judge", "rubric": "muss Frist nennen"}]
v = Verifier(registry, fake_chat)
fake_chat.handlers["verifier-small"] = lambda m: "FAIL: Frist fehlt"
r = v.verify(skill, "Vertrag", "Antwort")
assert not r.passed and r.failed_checks[0].detail.startswith("FAIL")
fake_chat.handlers["verifier-small"] = lambda m: "PASS"
assert v.verify(skill, "Vertrag", "Antwort").passed
def test_judge_unavailable_is_inconclusive(registry, library, fake_chat):
skill = library.get("vertrag-pruefen").model_copy(deep=True)
skill.meta.verifier = [{"type": "judge"}, {"type": "contains", "value": "Frist"}]
registry.set_status("verifier-small", "unavailable")
r = Verifier(registry, fake_chat).verify(skill, "Vertrag", "Frist 3 Monate")
assert r.passed and r.inconclusive
def test_inconclusive_case_not_used_for_training(make_runner, registry, library, store):
library.set_state("vertrag-pruefen", status="active")
registry.set_status("verifier-small", "unavailable")
skill_file = library._skills["vertrag-pruefen"]
skill_file.meta.verifier = [{"type": "judge"}]
runner = make_runner(**{"tier1-general": lambda m: "Kündigungsfrist: 3 Monate"})
res = runner.run("Vertrag prüfen auf Kündigungsfrist")
assert res.status == "ok"
assert store.list_cases()[0]["passed"] is None
def test_verifier_rules():
from knaster.skills import KnasterMeta, Skill
skill = Skill(name="s", description="d", body="b", meta=KnasterMeta(verifier=[
{"type": "regex", "pattern": r"^\d+$"}, {"type": "not_contains", "value": "Fehler"},
{"type": "max_chars", "value": 5}, {"type": "unbekannt"}]))
r = Verifier().verify(skill, "t", "123")
assert [c.passed for c in r.checks] == [True, True, True, False]
def test_problem_helpers():
assert extract_json('```json\n{"a": 1}\n```') == {"a": 1}
with pytest.raises(ValueError):
extract_json("{a:")
assert repetition_ratio("a b c d e f") == 0
assert is_degenerate(" ".join(["la le lu"] * 20))
assert not is_degenerate("kurz kurz kurz kurz")
def test_split_is_deterministic():
assert split_for("abc") == split_for("abc")
splits = {split_for(f"text {i}") for i in range(100)}
assert splits == {"train", "eval"}
def test_exports(make_runner, store):
runner = make_runner(**{"rechnungen-v1": lambda m: '{"nummer": "1"}', "tier0-strong": lambda m: GOOD})
for i in range(30):
runner.run(f"{RECHNUNG} Nr {i}")
train = list(export_sft(store, cluster="rechnungen", split="train"))
evals = list(export_sft(store, cluster="rechnungen", split="eval"))
assert len(train) + len(evals) == 30
row = json.loads(train[0])
assert row["messages"][-1] == {"role": "assistant", "content": GOOD}
assert row["meta"]["stage"] == 0
prefs = list(export_preferences(store, cluster="rechnungen"))
assert prefs and json.loads(prefs[0])["chosen"] == GOOD
def test_effective_context_calibration():
obs = [(1000, True)] * 30 + [(3000, True)] * 28 + [(3000, False)] * 2 + [(6000, True)] * 15 + \
[(6000, False)] * 10 + [(12000, True)] * 5
assert effective_context(obs, target=0.9, min_n=20) == 4096
assert effective_context([(1000, True)] * 5) is None
# Buckets ohne genug Daten unterbrechen nicht
assert effective_context([(1000, True)] * 25 + [(20000, True)] * 25, min_n=20) == 32768
def test_classifier_persistence(tmp_path, classifiers, embedder):
clf = classifiers["ticket-kat"]
clf.save(tmp_path / "c.json")
loaded = EmbeddingClassifier.load(tmp_path / "c.json")
assert loaded.predict(embedder.embed(["Drucker"])[0])[0] == "drucker"
+78
View File
@@ -0,0 +1,78 @@
from knaster.registry import AdapterSpec, Registry
from knaster.skills import compact_body, parse_skill
def test_slot_effective_context_prefers_calibration(registry):
slot = registry.slot("tier1-general")
assert slot.effective_context == 2000
registry.set_calibrated_context("tier1-general", 1500)
assert registry.slot("tier1-general").effective_context == 1500
def test_effective_context_never_exceeds_max(registry):
registry.set_calibrated_context("tier1-general", 999999)
assert registry.slot("tier1-general").effective_context == 8192
def test_status_and_strong_slot(registry):
registry.set_status("tier1-general", "degraded", reason="test", set_by="healer")
slot = registry.slot("tier1-general")
assert not slot.available and slot.set_by == "healer"
assert registry.strong_slot().name == "tier0-strong"
def test_unknown_fallback_rejected(store):
import pytest
with pytest.raises(ValueError):
Registry.from_dict({"slots": {"a": {"tier": 1, "litellm_model": "a", "base_model": "b",
"max_context": 10, "fallbacks": ["x"]}}}, store)
def test_adapter_overlay_and_active(registry):
assert registry.active_adapter("base-14b", "rechnungen").name == "rechnungen-v1"
registry.set_adapter(AdapterSpec(name="rechnungen-v2", base_model="base-14b", cluster="rechnungen",
version=2, status="active"))
assert registry.active_adapter("base-14b", "rechnungen").name == "rechnungen-v2"
registry.set_adapter(AdapterSpec(name="rechnungen-v2", base_model="base-14b", cluster="rechnungen",
version=2, status="retired"))
assert registry.active_adapter("base-14b", "rechnungen").name == "rechnungen-v1"
assert registry.active_adapter("other-base", "rechnungen") is None
def test_base_model_override_and_export(registry):
registry.set_base_model("tier1-general", "new-base")
assert registry.slot("tier1-general").base_model == "new-base"
exported = registry.export()
assert exported["slots"]["tier1-general"]["base_model"] == "new-base"
def test_parse_skill_and_compact(library):
skill = library.get("rechnung-extrahieren")
assert skill.cluster == "rechnungen"
assert skill.meta.output.format == "json"
assert "Beispiele" in skill.prompt("full")
assert "Beispiele" not in skill.prompt("compact")
assert "Lies Nummer" in skill.prompt("compact")
def test_compact_body_keeps_following_sections():
body = "# T\nA\n## Beispiele\nx\ny\n## Regeln\nR\n"
assert compact_body(body) == "# T\nA\n## Regeln\nR"
def test_parse_skill_requires_fields():
import pytest
with pytest.raises(ValueError):
parse_skill("---\nname: x\n---\nbody")
with pytest.raises(ValueError):
parse_skill("kein frontmatter")
def test_routable_and_state_overlay(library):
names = {s.name for s in library.routable()}
assert "entwurf" not in names and "vertrag-pruefen" in names
library.set_state("rechnung-extrahieren", status="demoted", reason="drift")
assert library.get("rechnung-extrahieren").meta.status == "demoted"
assert library.get("rechnung-extrahieren").meta.stage == 1
+133
View File
@@ -0,0 +1,133 @@
import pytest
from knaster.problems import Signal
from knaster.router import Router, RouterConfig
RECHNUNG = "Rechnung auslesen: Rechnungsnummer und Betrag extrahieren. Rechnung 4711 über 99 EUR"
@pytest.fixture
def router(registry, library, embedder):
return Router(registry, library, embedder)
def test_routes_to_weak_slot_with_adapter(router):
d = router.route(RECHNUNG)
assert d.status == "routed"
assert (d.stage, d.slot, d.skill) == (1, "tier1-general", "rechnung-extrahieren")
assert d.model == d.adapter == "rechnungen-v1"
assert d.prompt_variant == "full"
def test_unknown_request_goes_to_strong(router):
d = router.route("Wie ist das Wetter morgen in Berlin?")
assert (d.stage, d.slot, d.skill) == (0, "tier0-strong", None)
assert Signal.NO_SKILL in d.signals
def test_draft_skill_not_routed(router):
d = router.route("Gedicht über Rechnung schreiben")
assert d.skill != "entwurf"
def test_demoted_skill_runs_on_strong_with_hint(router):
d = router.route("Vertrag prüfen auf Kündigungsfrist")
assert (d.stage, d.skill, d.prompt_variant) == (0, "vertrag-pruefen", "full")
def test_classifier_and_program_stages(router):
assert router.route("Ticket: Drucker druckt nicht").stage == 3
d = router.route("Text in Großbuchstaben: hallo welt")
assert (d.stage, d.slot) == (2, None)
def test_unavailable_slot_uses_fallback(router, registry):
registry.set_status("tier1-general", "degraded")
d = router.route(RECHNUNG)
assert d.slot == "tier1-flex"
assert Signal.SLOT_UNAVAILABLE in d.signals
def test_all_weak_slots_unavailable_goes_strong(router, registry):
registry.set_status("tier1-general", "unavailable")
registry.set_status("tier1-flex", "unavailable")
d = router.route(RECHNUNG)
assert d.stage == 0 and d.skill == "rechnung-extrahieren"
def test_long_input_uses_compact_then_larger_slot(router):
# Eingabe ~1585 Tokens: mit Beispielen (64) zu groß für 2000, kompakt (38) passt es
filler = "x" * (3 * 1585 - len(RECHNUNG) - 1)
d = router.route(RECHNUNG + " " + filler)
assert d.slot == "tier1-general" and d.prompt_variant == "compact"
assert Signal.COMPACT_PROMPT in d.signals
d = router.route(RECHNUNG + " " + "x" * (3 * 5000))
assert d.slot == "tier1-flex"
assert Signal.LARGER_SLOT in d.signals
def test_too_large_for_everything(router):
d = router.route(RECHNUNG + " " + "x" * (3 * 120000))
assert d.status == "too_large"
assert Signal.NEEDS_SPLIT in d.signals
def test_context_exceeded_goes_to_strong(router):
d = router.route(RECHNUNG + " " + "x" * (3 * 30000))
assert d.stage == 0 and Signal.CONTEXT_EXCEEDED in d.signals
def test_hint_skill(router):
d = router.route("irgendwas", hint_skill="rechnung-extrahieren")
assert d.skill == "rechnung-extrahieren" and d.similarity is None
with pytest.raises(KeyError):
router.route("x", hint_skill="entwurf")
def test_escalation_quality_goes_to_strong(router):
d = router.route(RECHNUNG)
e = router.escalate(d, Signal.VERIFIER_FAILED, RECHNUNG)
assert (e.stage, e.slot, e.attempt) == (0, "tier0-strong", 1)
f = router.escalate(e, Signal.VERIFIER_FAILED, RECHNUNG)
assert f.status == "failed"
def test_escalation_infra_uses_fallback_slot(router):
d = router.route(RECHNUNG)
e = router.escalate(d, Signal.ENDPOINT_ERROR, RECHNUNG)
assert (e.stage, e.slot) == (1, "tier1-flex")
f = router.escalate(e, Signal.ENDPOINT_ERROR, RECHNUNG)
assert f.stage == 0
def test_escalation_from_program_to_weak(router):
d = router.route("Text in Großbuchstaben: hallo welt")
e = router.escalate(d, Signal.PROGRAM_ERROR, "Text in Großbuchstaben: hallo welt")
assert e.stage == 1
def test_max_escalations(registry, library, embedder):
r = Router(registry, library, embedder, config=RouterConfig(max_escalations=1))
d = r.route(RECHNUNG)
e = r.escalate(d, Signal.ENDPOINT_ERROR, RECHNUNG)
assert r.escalate(e, Signal.ENDPOINT_ERROR, RECHNUNG).status == "failed"
class PreferRechnung:
def rerank(self, query, documents):
return [1.0 if "Rechnung" in d else 0.0 for d in documents]
def test_conflict_without_reranker_goes_strong(registry, library, embedder):
r = Router(registry, library, embedder, config=RouterConfig(conflict_margin=1.0))
d = r.route(RECHNUNG)
assert d.stage == 0 and Signal.SKILL_CONFLICT in d.signals
def test_conflict_with_reranker_trusts_reranker(registry, library, embedder):
r = Router(registry, library, embedder, reranker=PreferRechnung(), config=RouterConfig(conflict_margin=1.0))
d = r.route("Vertrag prüfen Kündigungsfrist. Rechnung auslesen Rechnungsnummer Betrag")
assert Signal.SKILL_CONFLICT in d.signals
assert "Reranking aktiv" in d.reasons
assert d.skill == "rechnung-extrahieren" and d.stage == 1