core: Registry, Skills, Router, Executor, Verifier, Runner mit Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user