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.
- klix_engine-0.1.0/.gitignore +18 -0
- klix_engine-0.1.0/LICENSE +21 -0
- klix_engine-0.1.0/PKG-INFO +146 -0
- klix_engine-0.1.0/README.md +122 -0
- klix_engine-0.1.0/examples/demo.py +89 -0
- klix_engine-0.1.0/examples/minimal.py +7 -0
- klix_engine-0.1.0/pyproject.toml +41 -0
- klix_engine-0.1.0/src/klix/__init__.py +15 -0
- klix_engine-0.1.0/src/klix/backbone.py +64 -0
- klix_engine-0.1.0/src/klix/engine.py +83 -0
- klix_engine-0.1.0/src/klix/heads.py +244 -0
- klix_engine-0.1.0/tests/test_engine.py +198 -0
- klix_engine-0.1.0/uv.lock +1706 -0
|
@@ -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,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)
|