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
+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