klix-engine 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+
8
+ # Umgebungen
9
+ .venv/
10
+ venv/
11
+
12
+ # Tooling
13
+ .pytest_cache/
14
+ .ruff_cache/
15
+
16
+ # IDE
17
+ .idea/
18
+ .vscode/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Max-Christoph Hänel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.5
2
+ Name: klix-engine
3
+ Version: 0.1.0
4
+ Summary: Entkoppelte Entscheidungs-Köpfe (Choice, Score, Flag) auf einem geteilten semantischen Backbone - lokal, deterministisch, ohne Modelltraining.
5
+ Author: Max-Christoph Hänel
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: classification,decision-engine,embeddings,nlp,routing,semantic-search
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Natural Language :: German
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: fastembed<1,>=0.3
21
+ Requires-Dist: numpy>=1.24
22
+ Requires-Dist: scikit-learn>=1.3
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Klix
26
+
27
+ Entkoppelte Entscheidungs-Köpfe auf einem geteilten semantischen Backbone.
28
+ Ein Text geht **genau einmal** durch das Embedding-Modell (Dense + Sparse), beliebig viele
29
+ Köpfe (`Choice`, `Score`, `Flag`) arbeiten anschließend auf den vorberechneten Vektoren –
30
+ jeder in seinem eigenen mathematischen Raum. Kein Modelltraining, keine Slot-Limits,
31
+ vollständig offline und CPU-only.
32
+
33
+ ## Architektur
34
+
35
+ ```text
36
+ Text ──► HybridBackbone (FastEmbed-Dense + TF-IDF-Sparse, einmalig ~10 ms)
37
+
38
+ ├──► Choice (Routing/Klassifikation: Max-Similarity + Keyword-Boost)
39
+ ├──► Score (kontinuierliche Achse: Low/High-Anker + Sigmoid)
40
+ ├──► Flag (Boolesch: 2/3-Klassen-Softmax mit Temperatur)
41
+ └──► eigene Köpfe (von BaseHead erben)
42
+ ```
43
+
44
+ - **Shared Backbone:** Der Text wird einmal embedding + einmal TF-IDF transformiert.
45
+ 3 Köpfe oder 50 Köpfe – die Extraktionskosten bleiben gleich.
46
+ - **Entkoppelte Köpfe:** Neue Optionen in einem `Choice` beeinflussen weder `Score`- noch
47
+ `Flag`-Ergebnisse. Jeder Kopf kapselt seine eigene Logik.
48
+ - **Deklarativ:** Nur Schemata mit Beispielsätzen definieren, `compile()`, fertig.
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ uv add klix-engine
54
+ # oder
55
+ pip install klix-engine
56
+ ```
57
+
58
+ Beim ersten Aufruf lädt FastEmbed das Modell `paraphrase-multilingual-MiniLM-L12-v2`
59
+ (~120 MB, einmalig, danach lokal gecacht). Danach läuft alles offline.
60
+
61
+ ## Schnellstart
62
+
63
+ ```python
64
+ from klix import DecisionEngine, Choice, Score, Flag
65
+
66
+ engine = DecisionEngine()
67
+
68
+ engine.add_head(
69
+ Choice(
70
+ name="target",
71
+ options={
72
+ "it_ops": ["VPN abgerissen", "Server down", "Rechner bootet nicht"],
73
+ "ot_plant": ["Roboterzelle steht", "SPS Fehler", "Taktzeit deviation"],
74
+ "finance": ["KST 4210 über Budget", "Rechnung freigeben"],
75
+ "facility": ["Öllache Halle 2", "Heizung defekt"],
76
+ },
77
+ )
78
+ )
79
+
80
+ engine.add_head(
81
+ Score(
82
+ name="urgency",
83
+ low_anchors=["Routine-Wartung", "Informelle Frage"],
84
+ high_anchors=["Notfall sofort", "Produktionsstillstand", "Akute Gefahr"],
85
+ min_val=0.0,
86
+ max_val=3.0,
87
+ )
88
+ )
89
+
90
+ engine.add_head(
91
+ Flag(
92
+ name="is_security",
93
+ true_anchors=["Hackerangriff", "Ransomware Befall", "Datenabfluss"],
94
+ false_anchors=["Hardware kaputt", "Netzwerkstörung", "Alltägliche Anfrage"],
95
+ threshold=0.5,
96
+ )
97
+ )
98
+
99
+ engine.compile()
100
+
101
+ res = engine.decide("plc-34 meldet fehler, förderband steht sofort!")
102
+
103
+ print(res) # <DecisionResult (11 ms): target=ot_plant, urgency=2.9, is_security=False>
104
+ print(res.target) # 'ot_plant'
105
+ print(res.urgency) # 2.87
106
+ print(res.is_security) # False
107
+ print(res.details("is_security")) # {'value': False, 'probability': 0.03}
108
+ ```
109
+
110
+ ## Eigene Köpfe
111
+
112
+ Von `BaseHead` erben und `evaluate(encoded)` implementieren:
113
+
114
+ ```python
115
+ import re
116
+ from klix import BaseHead
117
+
118
+ class RegExExtractionHead(BaseHead):
119
+ def __init__(self, name: str, pattern: str):
120
+ super().__init__(name)
121
+ self.re = re.compile(pattern)
122
+
123
+ def get_reference_texts(self) -> list[str]:
124
+ return [] # keine Referenztexte nötig
125
+
126
+ def fit(self, backbone) -> None:
127
+ pass
128
+
129
+ def evaluate(self, encoded) -> dict:
130
+ match = self.re.search(encoded.text)
131
+ return {"value": match.group(0) if match else None}
132
+ ```
133
+
134
+ ## Entwicklung
135
+
136
+ ```bash
137
+ uv sync # Abhängigkeiten installieren
138
+ uv run pytest # Tests
139
+ uv run python examples/demo.py
140
+ uv build # PyPI-Artefakte (wheel + sdist) nach dist/
141
+ uv publish # Hochladen (erfordert Token/Account)
142
+ ```
143
+
144
+ ## Lizenz
145
+
146
+ MIT
@@ -0,0 +1,122 @@
1
+ # Klix
2
+
3
+ Entkoppelte Entscheidungs-Köpfe auf einem geteilten semantischen Backbone.
4
+ Ein Text geht **genau einmal** durch das Embedding-Modell (Dense + Sparse), beliebig viele
5
+ Köpfe (`Choice`, `Score`, `Flag`) arbeiten anschließend auf den vorberechneten Vektoren –
6
+ jeder in seinem eigenen mathematischen Raum. Kein Modelltraining, keine Slot-Limits,
7
+ vollständig offline und CPU-only.
8
+
9
+ ## Architektur
10
+
11
+ ```text
12
+ Text ──► HybridBackbone (FastEmbed-Dense + TF-IDF-Sparse, einmalig ~10 ms)
13
+
14
+ ├──► Choice (Routing/Klassifikation: Max-Similarity + Keyword-Boost)
15
+ ├──► Score (kontinuierliche Achse: Low/High-Anker + Sigmoid)
16
+ ├──► Flag (Boolesch: 2/3-Klassen-Softmax mit Temperatur)
17
+ └──► eigene Köpfe (von BaseHead erben)
18
+ ```
19
+
20
+ - **Shared Backbone:** Der Text wird einmal embedding + einmal TF-IDF transformiert.
21
+ 3 Köpfe oder 50 Köpfe – die Extraktionskosten bleiben gleich.
22
+ - **Entkoppelte Köpfe:** Neue Optionen in einem `Choice` beeinflussen weder `Score`- noch
23
+ `Flag`-Ergebnisse. Jeder Kopf kapselt seine eigene Logik.
24
+ - **Deklarativ:** Nur Schemata mit Beispielsätzen definieren, `compile()`, fertig.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ uv add klix-engine
30
+ # oder
31
+ pip install klix-engine
32
+ ```
33
+
34
+ Beim ersten Aufruf lädt FastEmbed das Modell `paraphrase-multilingual-MiniLM-L12-v2`
35
+ (~120 MB, einmalig, danach lokal gecacht). Danach läuft alles offline.
36
+
37
+ ## Schnellstart
38
+
39
+ ```python
40
+ from klix import DecisionEngine, Choice, Score, Flag
41
+
42
+ engine = DecisionEngine()
43
+
44
+ engine.add_head(
45
+ Choice(
46
+ name="target",
47
+ options={
48
+ "it_ops": ["VPN abgerissen", "Server down", "Rechner bootet nicht"],
49
+ "ot_plant": ["Roboterzelle steht", "SPS Fehler", "Taktzeit deviation"],
50
+ "finance": ["KST 4210 über Budget", "Rechnung freigeben"],
51
+ "facility": ["Öllache Halle 2", "Heizung defekt"],
52
+ },
53
+ )
54
+ )
55
+
56
+ engine.add_head(
57
+ Score(
58
+ name="urgency",
59
+ low_anchors=["Routine-Wartung", "Informelle Frage"],
60
+ high_anchors=["Notfall sofort", "Produktionsstillstand", "Akute Gefahr"],
61
+ min_val=0.0,
62
+ max_val=3.0,
63
+ )
64
+ )
65
+
66
+ engine.add_head(
67
+ Flag(
68
+ name="is_security",
69
+ true_anchors=["Hackerangriff", "Ransomware Befall", "Datenabfluss"],
70
+ false_anchors=["Hardware kaputt", "Netzwerkstörung", "Alltägliche Anfrage"],
71
+ threshold=0.5,
72
+ )
73
+ )
74
+
75
+ engine.compile()
76
+
77
+ res = engine.decide("plc-34 meldet fehler, förderband steht sofort!")
78
+
79
+ print(res) # <DecisionResult (11 ms): target=ot_plant, urgency=2.9, is_security=False>
80
+ print(res.target) # 'ot_plant'
81
+ print(res.urgency) # 2.87
82
+ print(res.is_security) # False
83
+ print(res.details("is_security")) # {'value': False, 'probability': 0.03}
84
+ ```
85
+
86
+ ## Eigene Köpfe
87
+
88
+ Von `BaseHead` erben und `evaluate(encoded)` implementieren:
89
+
90
+ ```python
91
+ import re
92
+ from klix import BaseHead
93
+
94
+ class RegExExtractionHead(BaseHead):
95
+ def __init__(self, name: str, pattern: str):
96
+ super().__init__(name)
97
+ self.re = re.compile(pattern)
98
+
99
+ def get_reference_texts(self) -> list[str]:
100
+ return [] # keine Referenztexte nötig
101
+
102
+ def fit(self, backbone) -> None:
103
+ pass
104
+
105
+ def evaluate(self, encoded) -> dict:
106
+ match = self.re.search(encoded.text)
107
+ return {"value": match.group(0) if match else None}
108
+ ```
109
+
110
+ ## Entwicklung
111
+
112
+ ```bash
113
+ uv sync # Abhängigkeiten installieren
114
+ uv run pytest # Tests
115
+ uv run python examples/demo.py
116
+ uv build # PyPI-Artefakte (wheel + sdist) nach dist/
117
+ uv publish # Hochladen (erfordert Token/Account)
118
+ ```
119
+
120
+ ## Lizenz
121
+
122
+ MIT
@@ -0,0 +1,89 @@
1
+ """Demo: Klix-Engine mit drei Köpfen (Choice, Score, Flag) auf IT/OT-Tickets."""
2
+
3
+ import time
4
+
5
+ from klix import DecisionEngine, Flag, Score, Choice
6
+
7
+
8
+ def main() -> None:
9
+ engine = DecisionEngine()
10
+
11
+ engine.add_head(
12
+ Choice(
13
+ name="target",
14
+ options={
15
+ "it_ops": ["VPN abgerissen", "Server down", "Rechner bootet nicht", "web-02 timeout"],
16
+ "ot_plant": ["Roboterzelle steht", "SPS Fehler", "plc-34 fehler", "Taktzeit deviation"],
17
+ "finance": ["KST 4210 über Budget", "Rechnung freigeben", "Skonto abziehen"],
18
+ "facility": ["Schmiermittel-Pfütze Rutschgefahr", "Öllache Halle 2", "Heizung defekt"],
19
+ },
20
+ )
21
+ )
22
+
23
+ engine.add_head(
24
+ Score(
25
+ name="urgency",
26
+ low_anchors=["Routine-Wartung", "Informelle Frage", "Hat Zeit nächste Woche"],
27
+ high_anchors=["Notfall sofort", "Produktionsstillstand", "Akute Gefahr", "Kritischer Ausfall"],
28
+ min_val=0.0,
29
+ max_val=3.0,
30
+ )
31
+ )
32
+
33
+ engine.add_head(
34
+ Flag(
35
+ name="is_security",
36
+ true_anchors=["Hackerangriff", "Ransomware Befall", "Root login kompromittiert", "Datenabfluss"],
37
+ false_anchors=["Hardware kaputt", "Standard IT Problem", "Netzwerkstörung", "Alltägliche Anfrage"],
38
+ neutral_anchors=["Routineanfrage", "Allgemeine Frage", "Sonstiges Thema"],
39
+ threshold=0.5,
40
+ )
41
+ )
42
+
43
+ engine.compile()
44
+
45
+ tickets = [
46
+ "plc-34 meldet fehler, förderband steht sofort!",
47
+ "Kaffee ist alle, wer füllt die Maschine nach?",
48
+ "Halle 2 neben der Presse steht Öl auf dem Boden, jemand ist fast ausgerutscht",
49
+ "Verdächtiger Login auf dem Domain-Controller, Root-Zugriff um 3 Uhr nachts",
50
+ "VPN bricht bei Homeoffice ständig ab, Kundentermin in 10 Minuten",
51
+ "Rechnung 2024-118 bitte freigeben, Skonto läuft morgen ab",
52
+ "Roboterzelle 3 bleibt im Zyklus stehen, Taktzeit verdoppelt",
53
+ "Routine-Wartung der Lüftung ist nächste Woche geplant",
54
+ ]
55
+
56
+ print("=" * 72)
57
+ print("Klix-Engine Demo - 3 Köpfe, geteilter Backbone")
58
+ print("=" * 72)
59
+
60
+ for ticket in tickets:
61
+ result = engine.decide(ticket)
62
+ security = result.details("is_security")
63
+ print(f"\nTicket : {ticket}")
64
+ print(f"Result : {result}")
65
+ print(
66
+ f" target={result.target} (Konfidenz {result.details('target')['confidence']:.0%}) | "
67
+ f"urgency={result.urgency}/3.0 | "
68
+ f"is_security={result.is_security} (p={security['probability']:.1%})"
69
+ )
70
+
71
+ # Latenz-Test: 10 Durchläufe, zeigt Shared-Backbone-Kosten vs. Kopf-Kosten.
72
+ print("\n" + "=" * 72)
73
+ print("Latenzprofil (10 Durchläufe je Ticket)")
74
+ print("=" * 72)
75
+ total = 0.0
76
+ for ticket in tickets[:3]:
77
+ times = []
78
+ for _ in range(10):
79
+ result = engine.decide(ticket)
80
+ times.append(result.latency_ms)
81
+ avg = sum(times) / len(times)
82
+ total += avg
83
+ print(f" {avg:6.1f} ms Ø | {ticket[:50]}")
84
+ print(f"\nGeteilter Backbone: 8 Tickets × 3 Köpfe = 24 Kopf-Entscheidungen bei "
85
+ f"nur {total / 3:.0f} ms Ø Embedding-Kosten pro Durchlauf.")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
@@ -0,0 +1,7 @@
1
+ # Minimalbeispiel
2
+ from klix import DecisionEngine, Choice
3
+
4
+ engine = DecisionEngine()
5
+ engine.add_head(Choice(name="route", options={"ja": ["klar", "auf jeden"], "nein": ["lieber nicht"]}))
6
+ engine.compile()
7
+ print(engine.decide("klarer Fall"))
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "klix-engine"
3
+ version = "0.1.0"
4
+ description = "Entkoppelte Entscheidungs-Köpfe (Choice, Score, Flag) auf einem geteilten semantischen Backbone - lokal, deterministisch, ohne Modelltraining."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Max-Christoph Hänel" }]
9
+ keywords = ["nlp", "decision-engine", "embeddings", "classification", "routing", "semantic-search"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ "Natural Language :: German",
21
+ ]
22
+ dependencies = [
23
+ "fastembed>=0.3,<1",
24
+ "numpy>=1.24",
25
+ "scikit-learn>=1.3",
26
+ ]
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "pytest>=8.0",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/klix"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
@@ -0,0 +1,15 @@
1
+ """Klix: Entkoppelte Entscheidungs-Köpfe (Choice, Score, Flag) auf einem geteilten semantischen Backbone."""
2
+
3
+ from klix.engine import DecisionEngine, DecisionResult
4
+ from klix.heads import BaseHead, Choice, Flag, Score
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "DecisionEngine",
10
+ "DecisionResult",
11
+ "BaseHead",
12
+ "Choice",
13
+ "Score",
14
+ "Flag",
15
+ ]
@@ -0,0 +1,64 @@
1
+ """Geteilte Feature-Extraktion: ein Text geht genau einmal dense + sparse transformiert rein."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ from fastembed import TextEmbedding
8
+ from sklearn.feature_extraction.text import TfidfVectorizer
9
+
10
+ # Kompakte deutsche Stoppwortliste (bewusst klein, damit Fachbegriffe ihre
11
+ # Information behalten; erweiterbar via build_vocabulary(..., stop_words=...)).
12
+ _DEFAULT_GERMAN_STOPWORDS = [
13
+ "die", "der", "das", "ein", "eine", "einer", "eines", "einem", "einen",
14
+ "im", "in", "ist", "und", "für", "von", "mit", "an", "auf", "nach", "zu",
15
+ "nicht", "mehr", "wird", "wie", "was", "hier", "dort",
16
+ ]
17
+
18
+
19
+ @dataclass
20
+ class EncodedInput:
21
+ """Ergebnis der einmaligen Feature-Extraktion für einen Eingabetext."""
22
+
23
+ text: str
24
+ dense_vec: np.ndarray # normalisierter Dense-Vektor (MiniLM, 384 dim)
25
+ sparse_vec: Any # TF-IDF Sparse-Matrix (1 x V) oder None vor compile()
26
+
27
+
28
+ class HybridBackbone:
29
+ """Kapselt Dense- (FastEmbed) und Sparse- (TF-IDF) Repräsentation eines Texts.
30
+
31
+ Wird von der Engine genau einmal instanziiert. Der Text wird pro `decide()`-Aufruf
32
+ genau einmal encoded; alle Köpfe arbeiten anschließend auf `EncodedInput`.
33
+ """
34
+
35
+ def __init__(self, model_name: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"):
36
+ self.embed_model = TextEmbedding(model_name=model_name)
37
+ self.tfidf_vec: TfidfVectorizer | None = None
38
+ self.is_indexed = False
39
+
40
+ def build_vocabulary(
41
+ self,
42
+ all_texts: list[str],
43
+ stop_words: list[str] | None = None,
44
+ ) -> None:
45
+ """Baut den Sparse-Index über alle in den Köpfen hinterlegten Referenztexte auf."""
46
+ if stop_words is None:
47
+ stop_words = _DEFAULT_GERMAN_STOPWORDS
48
+ self.tfidf_vec = TfidfVectorizer(
49
+ analyzer="word",
50
+ token_pattern=r"(?u)\b[\w-]+\b",
51
+ lowercase=True,
52
+ stop_words=stop_words,
53
+ )
54
+ self.tfidf_vec.fit(all_texts)
55
+ self.is_indexed = True
56
+
57
+ def encode(self, text: str) -> EncodedInput:
58
+ """Erzeugt beide Vektoren in einem Rutsch."""
59
+ vec = np.array(list(self.embed_model.embed([text]))[0])
60
+ norm = float(np.linalg.norm(vec))
61
+ dense_norm = vec / (norm if norm > 0 else 1.0)
62
+
63
+ sparse = self.tfidf_vec.transform([text]) if self.is_indexed else None
64
+ return EncodedInput(text=text, dense_vec=dense_norm, sparse_vec=sparse)
@@ -0,0 +1,83 @@
1
+ """Die Orchestrierungs-Engine: bindet Backbone und Köpfe zusammen."""
2
+
3
+ import time
4
+
5
+ from klix.backbone import HybridBackbone
6
+ from klix.heads import BaseHead
7
+
8
+
9
+ class DecisionResult:
10
+ """Ergebnis eines `decide()`-Aufrufs; Kopf-Werte als Attribute abrufbar."""
11
+
12
+ def __init__(self, text: str, latency_ms: float, head_data: dict):
13
+ self.text = text
14
+ self.latency_ms = latency_ms
15
+ self.data = head_data
16
+
17
+ def __getattr__(self, name: str):
18
+ if name in self.data:
19
+ return self.data[name]["value"]
20
+ raise AttributeError(f"Kopf '{name}' existiert nicht im Ergebnis.")
21
+
22
+ def details(self, name: str) -> dict:
23
+ """Vollständiges Ergebnis-Dict eines einzelnen Kopfs."""
24
+ return self.data.get(name, {})
25
+
26
+ def __repr__(self):
27
+ items = [f"{key}={value['value']}" for key, value in self.data.items()]
28
+ return f"<DecisionResult ({self.latency_ms:.1f}ms): {', '.join(items)}>"
29
+
30
+
31
+ class DecisionEngine:
32
+ """Shared-Backbone-Engine mit beliebig vielen entkoppelten Köpfen.
33
+
34
+ Der Text wird pro `decide()` genau einmal encoded; alle Köpfe evaluieren
35
+ danach parallel auf denselben Vektoren.
36
+ """
37
+
38
+ def __init__(self, model_name: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"):
39
+ self.backbone = HybridBackbone(model_name=model_name)
40
+ self.heads: list[BaseHead] = []
41
+ self._compiled = False
42
+
43
+ def add_head(self, head: BaseHead) -> "DecisionEngine":
44
+ """Registriert einen Kopf (fluent, verkettbar)."""
45
+ self.heads.append(head)
46
+ self._compiled = False
47
+ return self
48
+
49
+ def compile(self) -> None:
50
+ """Sammelt alle Texte aller Köpfe ein und initialisiert die Indizes."""
51
+ all_texts = []
52
+ for head in self.heads:
53
+ all_texts.extend(head.get_reference_texts())
54
+
55
+ if not all_texts:
56
+ raise ValueError("Keine Referenztexte vorhanden: erst Köpfe via add_head() registrieren.")
57
+
58
+ self.backbone.build_vocabulary(all_texts)
59
+
60
+ for head in self.heads:
61
+ head.fit(self.backbone)
62
+
63
+ self._compiled = True
64
+
65
+ def decide(self, text: str) -> DecisionResult:
66
+ """Encoded den Text einmal und evaluiert alle Köpfe auf den Vektoren."""
67
+ if not self.heads:
68
+ raise ValueError("Keine Köpfe registriert: erst add_head() aufrufen.")
69
+ if not self._compiled:
70
+ self.compile()
71
+
72
+ start = time.perf_counter()
73
+
74
+ # 1. Einmalige Vektorisierung (~10-12 ms).
75
+ encoded = self.backbone.encode(text)
76
+
77
+ # 2. Evaluation aller Köpfe (< 0.2 ms insgesamt).
78
+ results: dict[str, dict] = {}
79
+ for head in self.heads:
80
+ results[head.name] = head.evaluate(encoded)
81
+
82
+ elapsed_ms = (time.perf_counter() - start) * 1000
83
+ return DecisionResult(text=text, latency_ms=elapsed_ms, head_data=results)