winnex-ai-normalize 1.0.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.
- winnex_ai_normalize-1.0.0/LICENSE +16 -0
- winnex_ai_normalize-1.0.0/PKG-INFO +139 -0
- winnex_ai_normalize-1.0.0/README.md +93 -0
- winnex_ai_normalize-1.0.0/pyproject.toml +48 -0
- winnex_ai_normalize-1.0.0/setup.cfg +4 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize/__init__.py +42 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize/api/__init__.py +0 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize/api/server.py +78 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize/core/__init__.py +1 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize/core/config.py +97 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize/core/embedding.py +166 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize/core/normalize.py +188 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize/tests/__init__.py +0 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize/tests/test_normalize.py +98 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize.egg-info/PKG-INFO +139 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize.egg-info/SOURCES.txt +17 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize.egg-info/dependency_links.txt +1 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize.egg-info/requires.txt +14 -0
- winnex_ai_normalize-1.0.0/winnex_ai_normalize.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
4
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
5
|
+
|
|
6
|
+
Parameters
|
|
7
|
+
|
|
8
|
+
Licensor: Winnex AI
|
|
9
|
+
Licensed Work: Tracer-GOV
|
|
10
|
+
Additional Use Grant: Free for Brazilian government agencies.
|
|
11
|
+
Any use by a public sector entity in Brazil is permitted.
|
|
12
|
+
|
|
13
|
+
Change Date: 2036-01-01
|
|
14
|
+
Change License: GNU General Public License v2.0 or later
|
|
15
|
+
|
|
16
|
+
For inquiries: pay@winnex.ai
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: winnex-ai-normalize
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Input normalization plug for the Madhava engine — embed text via any provider (OpenAI, Qwen3, nano, xfactor), validate/normalize to float32/uint8, with provider failover and NO fake-vector fallback. Consumed by winnex-madhava, winnex-tracer, Liferay and the Maestro.
|
|
5
|
+
Author-email: Winnex Brasil Soluções Empresariais LTDA-ME <pay@winnex.ai>
|
|
6
|
+
License: Business Source License 1.1
|
|
7
|
+
|
|
8
|
+
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
9
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
|
|
13
|
+
Licensor: Winnex AI
|
|
14
|
+
Licensed Work: Tracer-GOV
|
|
15
|
+
Additional Use Grant: Free for Brazilian government agencies.
|
|
16
|
+
Any use by a public sector entity in Brazil is permitted.
|
|
17
|
+
|
|
18
|
+
Change Date: 2036-01-01
|
|
19
|
+
Change License: GNU General Public License v2.0 or later
|
|
20
|
+
|
|
21
|
+
For inquiries: pay@winnex.ai
|
|
22
|
+
|
|
23
|
+
Project-URL: Homepage, https://winnex.ai
|
|
24
|
+
Project-URL: Source, https://github.com/winnex-ai/winnex-ai-normalize
|
|
25
|
+
Keywords: embedding,normalize,madhava,vector,openai,qwen3,rag,semantic-search,normalizer,winnex,failover
|
|
26
|
+
Classifier: Development Status :: 4 - Beta
|
|
27
|
+
Classifier: Intended Audience :: Developers
|
|
28
|
+
Classifier: License :: Other/Proprietary License
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
31
|
+
Requires-Python: >=3.10
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
License-File: LICENSE
|
|
34
|
+
Requires-Dist: numpy>=1.24
|
|
35
|
+
Requires-Dist: httpx>=0.24
|
|
36
|
+
Provides-Extra: api
|
|
37
|
+
Requires-Dist: fastapi>=0.100; extra == "api"
|
|
38
|
+
Requires-Dist: uvicorn>=0.20; extra == "api"
|
|
39
|
+
Provides-Extra: madhava
|
|
40
|
+
Requires-Dist: winnex-madhava>=1.9.2; extra == "madhava"
|
|
41
|
+
Provides-Extra: all
|
|
42
|
+
Requires-Dist: fastapi>=0.100; extra == "all"
|
|
43
|
+
Requires-Dist: uvicorn>=0.20; extra == "all"
|
|
44
|
+
Requires-Dist: winnex-madhava>=1.9.2; extra == "all"
|
|
45
|
+
Dynamic: license-file
|
|
46
|
+
|
|
47
|
+
# winnex-ai-normalize
|
|
48
|
+
|
|
49
|
+
**The input-normalization plug for the Madhava engine.**
|
|
50
|
+
|
|
51
|
+
The Madhava engine is **agnostic** — it consumes float32 vectors. This
|
|
52
|
+
package normalizes **any** input (text via an embedding provider, or raw
|
|
53
|
+
vectors) into a valid float32/uint8 corpus ready for
|
|
54
|
+
`winnex_madhava.build_engine`, with **provider failover** and **no silent
|
|
55
|
+
fallback to fake vectors**.
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
text / raw vectors
|
|
59
|
+
│
|
|
60
|
+
▼
|
|
61
|
+
winnex-ai-normalize
|
|
62
|
+
├── providers: OpenAI / Qwen3 / nano / xfactor / direct (failover order)
|
|
63
|
+
├── validate: dim + NaN + norm (fail loudly)
|
|
64
|
+
├── normalize: L2 unit-norm (cosine contract)
|
|
65
|
+
└── quantize: float32 → uint8 (the engine's native corpus)
|
|
66
|
+
│
|
|
67
|
+
▼
|
|
68
|
+
winnex-madhava (agnostic) → top-K + Cauchy-Schwarz proof
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install winnex-ai-normalize # core (numpy + httpx)
|
|
75
|
+
pip install winnex-ai-normalize[api] # + OpenAI-compatible /v1/embeddings
|
|
76
|
+
pip install winnex-ai-normalize[all] # + winnex-madhava
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Quick start
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from winnex_ai_normalize import EmbeddingNormalizer
|
|
83
|
+
|
|
84
|
+
norm = EmbeddingNormalizer() # reads env / JSON config
|
|
85
|
+
|
|
86
|
+
# 1. Embed text via the configured provider (OpenAI / Qwen3 / ...)
|
|
87
|
+
vecs = norm.vectorize_texts(["hypertension treatment", "diabetes care"])
|
|
88
|
+
# vecs: (2, d) float32 L2-normalized
|
|
89
|
+
|
|
90
|
+
# 2. Or normalize raw vectors (validated, no NaN, unit-norm)
|
|
91
|
+
vecs = norm.normalize_vectors(my_embeddings, dim=1024)
|
|
92
|
+
|
|
93
|
+
# 3. Feed the Madhava engine (agnostic)
|
|
94
|
+
u8 = norm.to_corpus(vecs, dim=1024) # uint8 corpus for build_engine
|
|
95
|
+
import winnex_madhava as wm
|
|
96
|
+
engine = wm.build_engine(u8, dim=1024, metric="cosine", k=10)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## OpenAI-compatible API
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uvicorn winnex_ai_normalize.api.server:app --port 8102
|
|
103
|
+
curl -X POST http://localhost:8102/v1/embeddings \
|
|
104
|
+
-H "Content-Type: application/json" \
|
|
105
|
+
-d '{"model": "Qwen3-Embedding-0.6B", "input": ["text one", "text two"]}'
|
|
106
|
+
# → {"data": [{"embedding": [...], "index": 0}, ...], ...}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Provider failover (no fake fallback)
|
|
110
|
+
|
|
111
|
+
Providers are tried in `provider_order` (JSON/env config). If **all**
|
|
112
|
+
fail, a `RuntimeError` is raised with the collected errors — a normalizer
|
|
113
|
+
must never fabricate embeddings.
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"default_dim": 1024,
|
|
118
|
+
"default_provider": "qwen3",
|
|
119
|
+
"providers": [
|
|
120
|
+
{"name": "qwen3", "base_url": "http://winnex-embedding:8102",
|
|
121
|
+
"model": "/workspace/models/Qwen3-Embedding-0.6B", "priority": 1},
|
|
122
|
+
{"name": "openai", "base_url": "https://api.openai.com/v1",
|
|
123
|
+
"model": "text-embedding-3-small", "api_key_env": "OPENAI_API_KEY", "priority": 2}
|
|
124
|
+
],
|
|
125
|
+
"provider_order": ["qwen3", "openai"]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Consumed by
|
|
130
|
+
|
|
131
|
+
- **winnex-madhava** (direct — the engine stays agnostic)
|
|
132
|
+
- **winnex-tracer** (audit + commitment)
|
|
133
|
+
- **Liferay bridges** (tracer-gov-liferay, tracer-med-liferay)
|
|
134
|
+
- **Maestro** (winnex-ai-server / winnex-ai-engine) and any external tool
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
Business Source License 1.1 (BSL 1.1) | pay@winnex.ai |
|
|
139
|
+
Winnex Brasil Soluções Empresariais LTDA (CNPJ 58.364.637/0001-47)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# winnex-ai-normalize
|
|
2
|
+
|
|
3
|
+
**The input-normalization plug for the Madhava engine.**
|
|
4
|
+
|
|
5
|
+
The Madhava engine is **agnostic** — it consumes float32 vectors. This
|
|
6
|
+
package normalizes **any** input (text via an embedding provider, or raw
|
|
7
|
+
vectors) into a valid float32/uint8 corpus ready for
|
|
8
|
+
`winnex_madhava.build_engine`, with **provider failover** and **no silent
|
|
9
|
+
fallback to fake vectors**.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
text / raw vectors
|
|
13
|
+
│
|
|
14
|
+
▼
|
|
15
|
+
winnex-ai-normalize
|
|
16
|
+
├── providers: OpenAI / Qwen3 / nano / xfactor / direct (failover order)
|
|
17
|
+
├── validate: dim + NaN + norm (fail loudly)
|
|
18
|
+
├── normalize: L2 unit-norm (cosine contract)
|
|
19
|
+
└── quantize: float32 → uint8 (the engine's native corpus)
|
|
20
|
+
│
|
|
21
|
+
▼
|
|
22
|
+
winnex-madhava (agnostic) → top-K + Cauchy-Schwarz proof
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install winnex-ai-normalize # core (numpy + httpx)
|
|
29
|
+
pip install winnex-ai-normalize[api] # + OpenAI-compatible /v1/embeddings
|
|
30
|
+
pip install winnex-ai-normalize[all] # + winnex-madhava
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from winnex_ai_normalize import EmbeddingNormalizer
|
|
37
|
+
|
|
38
|
+
norm = EmbeddingNormalizer() # reads env / JSON config
|
|
39
|
+
|
|
40
|
+
# 1. Embed text via the configured provider (OpenAI / Qwen3 / ...)
|
|
41
|
+
vecs = norm.vectorize_texts(["hypertension treatment", "diabetes care"])
|
|
42
|
+
# vecs: (2, d) float32 L2-normalized
|
|
43
|
+
|
|
44
|
+
# 2. Or normalize raw vectors (validated, no NaN, unit-norm)
|
|
45
|
+
vecs = norm.normalize_vectors(my_embeddings, dim=1024)
|
|
46
|
+
|
|
47
|
+
# 3. Feed the Madhava engine (agnostic)
|
|
48
|
+
u8 = norm.to_corpus(vecs, dim=1024) # uint8 corpus for build_engine
|
|
49
|
+
import winnex_madhava as wm
|
|
50
|
+
engine = wm.build_engine(u8, dim=1024, metric="cosine", k=10)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## OpenAI-compatible API
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
uvicorn winnex_ai_normalize.api.server:app --port 8102
|
|
57
|
+
curl -X POST http://localhost:8102/v1/embeddings \
|
|
58
|
+
-H "Content-Type: application/json" \
|
|
59
|
+
-d '{"model": "Qwen3-Embedding-0.6B", "input": ["text one", "text two"]}'
|
|
60
|
+
# → {"data": [{"embedding": [...], "index": 0}, ...], ...}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Provider failover (no fake fallback)
|
|
64
|
+
|
|
65
|
+
Providers are tried in `provider_order` (JSON/env config). If **all**
|
|
66
|
+
fail, a `RuntimeError` is raised with the collected errors — a normalizer
|
|
67
|
+
must never fabricate embeddings.
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"default_dim": 1024,
|
|
72
|
+
"default_provider": "qwen3",
|
|
73
|
+
"providers": [
|
|
74
|
+
{"name": "qwen3", "base_url": "http://winnex-embedding:8102",
|
|
75
|
+
"model": "/workspace/models/Qwen3-Embedding-0.6B", "priority": 1},
|
|
76
|
+
{"name": "openai", "base_url": "https://api.openai.com/v1",
|
|
77
|
+
"model": "text-embedding-3-small", "api_key_env": "OPENAI_API_KEY", "priority": 2}
|
|
78
|
+
],
|
|
79
|
+
"provider_order": ["qwen3", "openai"]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Consumed by
|
|
84
|
+
|
|
85
|
+
- **winnex-madhava** (direct — the engine stays agnostic)
|
|
86
|
+
- **winnex-tracer** (audit + commitment)
|
|
87
|
+
- **Liferay bridges** (tracer-gov-liferay, tracer-med-liferay)
|
|
88
|
+
- **Maestro** (winnex-ai-server / winnex-ai-engine) and any external tool
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
Business Source License 1.1 (BSL 1.1) | pay@winnex.ai |
|
|
93
|
+
Winnex Brasil Soluções Empresariais LTDA (CNPJ 58.364.637/0001-47)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "winnex-ai-normalize"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Input normalization plug for the Madhava engine — embed text via any provider (OpenAI, Qwen3, nano, xfactor), validate/normalize to float32/uint8, with provider failover and NO fake-vector fallback. Consumed by winnex-madhava, winnex-tracer, Liferay and the Maestro."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Winnex Brasil Soluções Empresariais LTDA-ME", email = "pay@winnex.ai" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"embedding", "normalize", "madhava", "vector", "openai", "qwen3",
|
|
17
|
+
"rag", "semantic-search", "normalizer", "winnex", "failover",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: Other/Proprietary License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"numpy>=1.24",
|
|
28
|
+
"httpx>=0.24",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
api = ["fastapi>=0.100", "uvicorn>=0.20"]
|
|
33
|
+
madhava = ["winnex-madhava>=1.9.2"]
|
|
34
|
+
all = [
|
|
35
|
+
"fastapi>=0.100",
|
|
36
|
+
"uvicorn>=0.20",
|
|
37
|
+
"winnex-madhava>=1.9.2",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://winnex.ai"
|
|
42
|
+
Source = "https://github.com/winnex-ai/winnex-ai-normalize"
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["winnex_ai_normalize/tests"]
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
include = ["winnex_ai_normalize*"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""winnex-ai-normalize — the input-normalization plug for the Madhava engine.
|
|
2
|
+
|
|
3
|
+
The Madhava engine is AGNOSTIC: it consumes float32 vectors. This package
|
|
4
|
+
normalizes ANY input (text via an embedding provider, or raw vectors) into a
|
|
5
|
+
valid float32/uint8 corpus ready for `winnex_madhava.build_engine`, with
|
|
6
|
+
provider failover and NO silent fallback to fake vectors.
|
|
7
|
+
|
|
8
|
+
Components:
|
|
9
|
+
- core.config: JSON-driven provider/config (model, dim, failover order)
|
|
10
|
+
- core.embedding: EmbeddingService with provider failover + cache
|
|
11
|
+
- core.normalize: validate / L2-normalize / quantize → Madhava corpus
|
|
12
|
+
- api.server: OpenAI-compatible /v1/embeddings endpoint
|
|
13
|
+
|
|
14
|
+
Consumed by: winnex-madhava (direct), winnex-tracer, the Liferay bridges,
|
|
15
|
+
and the Maestro — any tool that needs to feed vectors to Madhava.
|
|
16
|
+
|
|
17
|
+
Business Source License 1.1 (BSL 1.1) | pay@winnex.ai
|
|
18
|
+
"""
|
|
19
|
+
from .core.config import NormalizeConfig, ProviderConfig, load_config
|
|
20
|
+
from .core.embedding import EmbeddingProvider, EmbeddingService, get_embedding_service
|
|
21
|
+
from .core.normalize import (
|
|
22
|
+
EmbeddingNormalizer,
|
|
23
|
+
validate_embeddings,
|
|
24
|
+
normalize_l2,
|
|
25
|
+
quantize_corpus,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = "1.0.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"NormalizeConfig",
|
|
32
|
+
"ProviderConfig",
|
|
33
|
+
"load_config",
|
|
34
|
+
"EmbeddingProvider",
|
|
35
|
+
"EmbeddingService",
|
|
36
|
+
"get_embedding_service",
|
|
37
|
+
"EmbeddingNormalizer",
|
|
38
|
+
"validate_embeddings",
|
|
39
|
+
"normalize_l2",
|
|
40
|
+
"quantize_corpus",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
winnex-ai-normalize — OpenAI-compatible embedding API.
|
|
3
|
+
|
|
4
|
+
Exposes the normalization service behind the OpenAI `/v1/embeddings`
|
|
5
|
+
contract, so any consumer (Liferay, Maestro, tracer, or a plain OpenAI
|
|
6
|
+
client) can get Madhava-ready vectors:
|
|
7
|
+
|
|
8
|
+
POST /v1/embeddings {"model": "...", "input": ["text", ...]}
|
|
9
|
+
→ {"data": [{"embedding": [..], "index": 0}], "model": "...", "usage": {...}}
|
|
10
|
+
|
|
11
|
+
Also exposes:
|
|
12
|
+
GET /v1/health — provider availability
|
|
13
|
+
GET /v1/normalize/health — alias (Liferay-friendly)
|
|
14
|
+
|
|
15
|
+
License: Business Source License 1.1 (BSL 1.1)
|
|
16
|
+
"""
|
|
17
|
+
import os
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from fastapi import FastAPI, HTTPException
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
|
|
23
|
+
from winnex_ai_normalize.core.embedding import get_embedding_service
|
|
24
|
+
from winnex_ai_normalize.core.config import load_config
|
|
25
|
+
|
|
26
|
+
app = FastAPI(
|
|
27
|
+
title="winnex-ai-normalize",
|
|
28
|
+
version="1.0.0",
|
|
29
|
+
description="Input normalization for the Madhava engine (OpenAI-compatible embeddings).",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EmbeddingsRequest(BaseModel):
|
|
34
|
+
model: str = ""
|
|
35
|
+
input: List[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.post("/v1/embeddings")
|
|
39
|
+
def embeddings(req: EmbeddingsRequest):
|
|
40
|
+
"""OpenAI-compatible embeddings endpoint (Madhava-ready vectors)."""
|
|
41
|
+
service = get_embedding_service()
|
|
42
|
+
try:
|
|
43
|
+
vecs = service.embed_texts(req.input)
|
|
44
|
+
except RuntimeError as e:
|
|
45
|
+
raise HTTPException(503, str(e))
|
|
46
|
+
data = [
|
|
47
|
+
{"embedding": vecs[i].tolist(), "index": i}
|
|
48
|
+
for i in range(len(vecs))
|
|
49
|
+
]
|
|
50
|
+
return {
|
|
51
|
+
"object": "list",
|
|
52
|
+
"data": data,
|
|
53
|
+
"model": req.model or "winnex-ai-normalize",
|
|
54
|
+
"usage": {"prompt_tokens": sum(len(t) // 4 for t in req.input),
|
|
55
|
+
"total_tokens": sum(len(t) // 4 for t in req.input)},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.get("/v1/health")
|
|
60
|
+
@app.get("/v1/normalize/health")
|
|
61
|
+
def health():
|
|
62
|
+
"""Provider availability (fail loudly if none reachable)."""
|
|
63
|
+
cfg = load_config()
|
|
64
|
+
svc = get_embedding_service()
|
|
65
|
+
status = svc.check_available()
|
|
66
|
+
return {
|
|
67
|
+
"status": "ok" if status.get("available") else "degraded",
|
|
68
|
+
"service": "winnex-ai-normalize",
|
|
69
|
+
"default_dim": cfg.default_dim,
|
|
70
|
+
"default_provider": cfg.default_provider,
|
|
71
|
+
"provider_order": cfg.provider_order,
|
|
72
|
+
"providers": status,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
import uvicorn
|
|
78
|
+
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8102")))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""winnex-ai-normalize — core (config, embedding providers, normalization)."""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
winnex-ai-normalize — configuration (JSON-driven).
|
|
3
|
+
|
|
4
|
+
The single source of truth for the normalization providers, the embedding
|
|
5
|
+
model and the provider priority/failover order. Mirrors the Maestro's
|
|
6
|
+
`ai_models_config` (JSON-driven, no hardcoded values).
|
|
7
|
+
|
|
8
|
+
Config sources, in order of precedence:
|
|
9
|
+
1. Constructor / env vars (WINNEX_AI_NORMALIZE_*)
|
|
10
|
+
2. A JSON file path (WINNEX_AI_NORMALIZE_CONFIG)
|
|
11
|
+
3. Sensible defaults.
|
|
12
|
+
|
|
13
|
+
License: Business Source License 1.1 (BSL 1.1)
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ProviderConfig:
|
|
23
|
+
"""A normalization provider (embedding source)."""
|
|
24
|
+
name: str # "openai" | "qwen3" | "nano" | "xfactor" | "direct"
|
|
25
|
+
type: str = "openai_compat" # how to call it: openai_compat | local | direct
|
|
26
|
+
model: str = ""
|
|
27
|
+
base_url: str = ""
|
|
28
|
+
api_key_env: str = "" # env var holding the API key (never hardcoded)
|
|
29
|
+
dim: int = 0 # 0 = auto-detect
|
|
30
|
+
timeout: float = 20.0
|
|
31
|
+
priority: int = 10 # lower = tried first (failover order)
|
|
32
|
+
enabled: bool = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class NormalizeConfig:
|
|
37
|
+
"""Top-level normalization configuration."""
|
|
38
|
+
default_dim: int = 1024 # Qwen3-Embedding-0.6B
|
|
39
|
+
default_provider: str = "qwen3" # the first provider to try
|
|
40
|
+
normalize_l2: bool = True # L2-normalize embeddings (cosine contract)
|
|
41
|
+
validate_nan: bool = True # fail loudly on NaN/inf
|
|
42
|
+
validate_range: bool = False # warn (not fail) on out-of-range
|
|
43
|
+
cache_max: int = 512 # bounded embedding cache
|
|
44
|
+
providers: List[ProviderConfig] = field(default_factory=list)
|
|
45
|
+
provider_order: List[str] = field(default_factory=list) # failover order
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_env(cls) -> "NormalizeConfig":
|
|
49
|
+
"""Load from WINNEX_AI_NORMALIZE_* env vars / JSON file."""
|
|
50
|
+
cfg = cls()
|
|
51
|
+
cfg.default_dim = int(os.environ.get("WINNEX_AI_NORMALIZE_DIM", cfg.default_dim))
|
|
52
|
+
cfg.default_provider = os.environ.get(
|
|
53
|
+
"WINNEX_AI_NORMALIZE_PROVIDER", cfg.default_provider)
|
|
54
|
+
# A config JSON may define the providers + failover order.
|
|
55
|
+
json_path = os.environ.get("WINNEX_AI_NORMALIZE_CONFIG", "")
|
|
56
|
+
if json_path and os.path.exists(json_path):
|
|
57
|
+
with open(json_path) as f:
|
|
58
|
+
data = json.load(f)
|
|
59
|
+
cfg.default_dim = int(data.get("default_dim", cfg.default_dim))
|
|
60
|
+
cfg.default_provider = data.get("default_provider", cfg.default_provider)
|
|
61
|
+
cfg.normalize_l2 = bool(data.get("normalize_l2", cfg.normalize_l2))
|
|
62
|
+
cfg.cache_max = int(data.get("cache_max", cfg.cache_max))
|
|
63
|
+
for p in data.get("providers", []):
|
|
64
|
+
cfg.providers.append(ProviderConfig(**p))
|
|
65
|
+
cfg.provider_order = list(data.get("provider_order", []))
|
|
66
|
+
# Default providers if none configured.
|
|
67
|
+
if not cfg.providers:
|
|
68
|
+
cfg.providers = [
|
|
69
|
+
ProviderConfig(
|
|
70
|
+
name="qwen3", type="openai_compat",
|
|
71
|
+
model=os.environ.get("EMBEDDING_MODEL", "/workspace/models/Qwen3-Embedding-0.6B"),
|
|
72
|
+
base_url=os.environ.get("EMBEDDING_URL", "http://winnex-embedding:8102"),
|
|
73
|
+
dim=cfg.default_dim, priority=1),
|
|
74
|
+
ProviderConfig(
|
|
75
|
+
name="openai", type="openai_compat",
|
|
76
|
+
model=os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"),
|
|
77
|
+
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
|
78
|
+
api_key_env="OPENAI_API_KEY", priority=2),
|
|
79
|
+
ProviderConfig(
|
|
80
|
+
name="direct", type="direct",
|
|
81
|
+
dim=cfg.default_dim, priority=99),
|
|
82
|
+
]
|
|
83
|
+
if not cfg.provider_order:
|
|
84
|
+
cfg.provider_order = [p.name for p in sorted(
|
|
85
|
+
cfg.providers, key=lambda p: p.priority)]
|
|
86
|
+
return cfg
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_config() -> NormalizeConfig:
|
|
90
|
+
"""Convenience loader (cached)."""
|
|
91
|
+
global _CONFIG
|
|
92
|
+
if _CONFIG is None:
|
|
93
|
+
_CONFIG = NormalizeConfig.from_env()
|
|
94
|
+
return _CONFIG
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_CONFIG: Optional[NormalizeConfig] = None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
winnex-ai-normalize — embedding providers (with failover).
|
|
3
|
+
|
|
4
|
+
Extracted from the Maestro's `EmbeddingService` (winnex-madhava-maestro):
|
|
5
|
+
connects to an OpenAI-compatible `/v1/embeddings` endpoint (e.g.
|
|
6
|
+
Qwen3-Embedding-0.6B), with a bounded LRU cache and L2 normalization.
|
|
7
|
+
|
|
8
|
+
NO fallback to fake vectors: if the configured providers are unavailable,
|
|
9
|
+
the error is explicit (a normalizer must not fabricate embeddings).
|
|
10
|
+
|
|
11
|
+
Provider failover: the providers are tried in `config.provider_order`
|
|
12
|
+
(lowest priority first); if one fails, the next is tried. If ALL fail,
|
|
13
|
+
a RuntimeError is raised with the collected errors.
|
|
14
|
+
"""
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("winnex-ai-normalize.embedding")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EmbeddingProvider:
|
|
26
|
+
"""A single embedding provider (OpenAI-compatible /v1/embeddings)."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config, http_client_factory=None):
|
|
29
|
+
self.name = config.name
|
|
30
|
+
self.model = config.model
|
|
31
|
+
self.base_url = config.base_url
|
|
32
|
+
self.timeout = config.timeout
|
|
33
|
+
self.api_key_env = config.api_key_env
|
|
34
|
+
self.dim = config.dim
|
|
35
|
+
self._http_factory = http_client_factory
|
|
36
|
+
|
|
37
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
38
|
+
import httpx
|
|
39
|
+
headers = {}
|
|
40
|
+
if self.api_key_env:
|
|
41
|
+
key = os.environ.get(self.api_key_env, "")
|
|
42
|
+
if key:
|
|
43
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
44
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
45
|
+
resp = client.post(f"{self.base_url}{path}", json=payload, headers=headers)
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
return resp.json()
|
|
48
|
+
|
|
49
|
+
def embed(self, texts: List[str]) -> np.ndarray:
|
|
50
|
+
"""Embed a list of texts → (n, d) float32 L2-normalized."""
|
|
51
|
+
result = self._post("/embeddings", {"model": self.model, "input": texts})
|
|
52
|
+
data = sorted(result.get("data", []), key=lambda x: x.get("index", 0))
|
|
53
|
+
if not data:
|
|
54
|
+
raise RuntimeError(f"provider {self.name}: empty embeddings response")
|
|
55
|
+
vecs = np.array([d["embedding"] for d in data], dtype=np.float32)
|
|
56
|
+
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
|
|
57
|
+
vecs = vecs / np.maximum(norms, 1e-12) # L2-normalize (cosine contract)
|
|
58
|
+
if not np.isfinite(vecs).all():
|
|
59
|
+
raise RuntimeError(f"provider {self.name}: embeddings contain NaN/inf")
|
|
60
|
+
self.dim = vecs.shape[1]
|
|
61
|
+
return np.ascontiguousarray(vecs, dtype=np.float32)
|
|
62
|
+
|
|
63
|
+
def check_available(self) -> dict:
|
|
64
|
+
try:
|
|
65
|
+
r = self._post("/embeddings", {"model": self.model, "input": ["health-check"]})
|
|
66
|
+
return {"available": bool(r.get("data")), "provider": self.name,
|
|
67
|
+
"model": self.model, "base_url": self.base_url}
|
|
68
|
+
except Exception as e:
|
|
69
|
+
return {"available": False, "provider": self.name, "error": str(e)}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class EmbeddingService:
|
|
73
|
+
"""Embedding service with provider failover + bounded cache.
|
|
74
|
+
|
|
75
|
+
Tries the providers in `config.provider_order`; the first that succeeds
|
|
76
|
+
serves the batch. If ALL fail, raises RuntimeError with the collected
|
|
77
|
+
errors (NO silent fallback to fake vectors).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, config=None, provider_factory=EmbeddingProvider):
|
|
81
|
+
from .config import load_config
|
|
82
|
+
self.config = config or load_config()
|
|
83
|
+
self._providers = {
|
|
84
|
+
p.name: provider_factory(p) for p in self.config.providers
|
|
85
|
+
}
|
|
86
|
+
self._cache: dict[str, np.ndarray] = {}
|
|
87
|
+
self._lock = threading.Lock()
|
|
88
|
+
|
|
89
|
+
def _cache_put(self, text: str, vec: np.ndarray) -> None:
|
|
90
|
+
with self._lock:
|
|
91
|
+
if len(self._cache) >= max(self.config.cache_max, 1):
|
|
92
|
+
for old in list(self._cache)[: max(0, len(self._cache) - self.config.cache_max + 1)]:
|
|
93
|
+
self._cache.pop(old, None)
|
|
94
|
+
self._cache[text] = vec
|
|
95
|
+
|
|
96
|
+
def _embed_missing(self, texts: List[str]) -> np.ndarray:
|
|
97
|
+
"""Embed via the first available provider (failover)."""
|
|
98
|
+
errors = []
|
|
99
|
+
for name in self.config.provider_order:
|
|
100
|
+
p = self._providers.get(name)
|
|
101
|
+
if not p or not p.__dict__.get("_available", True):
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
return p.embed(texts)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
errors.append(f"{name}: {str(e)[:120]}")
|
|
107
|
+
logger.warning(f"provider {name} failed: {e}")
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
"EmbeddingService: ALL providers unavailable — refusing to "
|
|
110
|
+
f"fabricate embeddings. Errors: {'; '.join(errors) or 'none configured'}")
|
|
111
|
+
|
|
112
|
+
def embed_texts(self, texts: List[str], dim: Optional[int] = None) -> np.ndarray:
|
|
113
|
+
"""Embed a list of texts → (n, d) float32, cache repeated texts.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
texts: list of strings.
|
|
117
|
+
dim: expected dimension (validated).
|
|
118
|
+
Returns:
|
|
119
|
+
(n, d) float32 L2-normalized embeddings.
|
|
120
|
+
"""
|
|
121
|
+
if not texts:
|
|
122
|
+
return np.zeros((0, dim or self.config.default_dim), dtype=np.float32)
|
|
123
|
+
with self._lock:
|
|
124
|
+
missing_idx = [i for i, t in enumerate(texts) if t not in self._cache]
|
|
125
|
+
if missing_idx:
|
|
126
|
+
missing_texts = [texts[i] for i in missing_idx]
|
|
127
|
+
result = self._embed_missing(missing_texts)
|
|
128
|
+
if dim and result.shape[1] != dim:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"provider returned dim {result.shape[1]}, expected {dim}")
|
|
131
|
+
for i, v in zip(missing_idx, result):
|
|
132
|
+
self._cache_put(texts[i], v)
|
|
133
|
+
with self._lock:
|
|
134
|
+
vecs = np.stack([self._cache[t] for t in texts]).astype(np.float32)
|
|
135
|
+
return np.ascontiguousarray(vecs, dtype=np.float32)
|
|
136
|
+
|
|
137
|
+
def embed_one(self, text: str) -> np.ndarray:
|
|
138
|
+
"""Embed a single text → (d,) float32."""
|
|
139
|
+
return self.embed_texts([text])[0]
|
|
140
|
+
|
|
141
|
+
def clear_cache(self) -> None:
|
|
142
|
+
with self._lock:
|
|
143
|
+
self._cache.clear()
|
|
144
|
+
|
|
145
|
+
def check_available(self) -> dict:
|
|
146
|
+
for name in self.config.provider_order:
|
|
147
|
+
p = self._providers.get(name)
|
|
148
|
+
if p:
|
|
149
|
+
r = p.check_available()
|
|
150
|
+
if r.get("available"):
|
|
151
|
+
return r
|
|
152
|
+
logger.warning(f"provider {name} unavailable: {r.get('error')}")
|
|
153
|
+
return {"available": False,
|
|
154
|
+
"error": "no provider available",
|
|
155
|
+
"tried": self.config.provider_order}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Singleton for reuse
|
|
159
|
+
_service = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_embedding_service() -> EmbeddingService:
|
|
163
|
+
global _service
|
|
164
|
+
if _service is None:
|
|
165
|
+
_service = EmbeddingService()
|
|
166
|
+
return _service
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
winnex-ai-normalize — embedding normalization (the plug that feeds Madhava).
|
|
3
|
+
|
|
4
|
+
The Madhava engine is AGNOSTIC: it consumes float32 vectors. This module
|
|
5
|
+
normalizes ANY input (text via a provider, or raw vectors) into a valid
|
|
6
|
+
float32 corpus ready for `winnex_madhava.build_engine`:
|
|
7
|
+
|
|
8
|
+
- validates dimension, dtype, NaN/inf (fail loudly — no silent fallback),
|
|
9
|
+
- L2-normalizes when the cosine contract requires it,
|
|
10
|
+
- quantizes float32 → uint8 for the engine's native corpus
|
|
11
|
+
(the exact scale logic from the Maestro's `quantize_corpus`).
|
|
12
|
+
|
|
13
|
+
License: Business Source License 1.1 (BSL 1.1)
|
|
14
|
+
"""
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Optional, Union
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("winnex-ai-normalize")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Validation (fail loudly — no fake embeddings)
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
def validate_embeddings(
|
|
27
|
+
vectors: np.ndarray,
|
|
28
|
+
dim: Optional[int] = None,
|
|
29
|
+
require_unit_norm: bool = False,
|
|
30
|
+
) -> np.ndarray:
|
|
31
|
+
"""Validate a (n, d) embedding matrix. Raises on invalid input.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
vectors: (n, d) float32 (or float64) embeddings.
|
|
35
|
+
dim: expected dimensionality (raises if mismatch).
|
|
36
|
+
require_unit_norm: if True, each row must be unit-norm (cosine).
|
|
37
|
+
Returns:
|
|
38
|
+
the array as float32, contiguous.
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: on wrong dim, NaN/inf, or empty.
|
|
41
|
+
"""
|
|
42
|
+
arr = np.ascontiguousarray(vectors, dtype=np.float32)
|
|
43
|
+
if arr.ndim != 2:
|
|
44
|
+
raise ValueError(f"embeddings must be 2D (n, d), got shape {arr.shape}")
|
|
45
|
+
if arr.shape[0] == 0:
|
|
46
|
+
raise ValueError("embeddings must not be empty")
|
|
47
|
+
if dim is not None and arr.shape[1] != dim:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"dimension mismatch: expected {dim}, got {arr.shape[1]}")
|
|
50
|
+
if not np.isfinite(arr).all():
|
|
51
|
+
raise ValueError("embeddings contain NaN or inf — refusing to proceed")
|
|
52
|
+
if require_unit_norm:
|
|
53
|
+
norms = np.linalg.norm(arr, axis=1)
|
|
54
|
+
bad = np.where((norms < 1e-6) | (np.abs(norms - 1.0) > 1e-2))[0]
|
|
55
|
+
if len(bad):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"{len(bad)} rows are not unit-norm (cosine contract) — "
|
|
58
|
+
"call normalize_l2() first or fix the provider")
|
|
59
|
+
return arr
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# L2 normalization (cosine contract)
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
def normalize_l2(vectors: np.ndarray) -> np.ndarray:
|
|
66
|
+
"""L2-normalize each row to unit norm (the cosine contract).
|
|
67
|
+
|
|
68
|
+
Raises on a zero-norm row (cannot be normalized meaningfully).
|
|
69
|
+
"""
|
|
70
|
+
arr = validate_embeddings(vectors)
|
|
71
|
+
norms = np.linalg.norm(arr, axis=1, keepdims=True)
|
|
72
|
+
if (norms < 1e-12).any():
|
|
73
|
+
raise ValueError("a row has zero norm — cannot L2-normalize")
|
|
74
|
+
return arr / np.maximum(norms, 1e-12)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Quantization float32 → uint8 (the engine's native corpus)
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
def quantize_corpus(embeddings: np.ndarray) -> np.ndarray:
|
|
81
|
+
"""Quantize float32 normalized embeddings to the engine's uint8 corpus.
|
|
82
|
+
|
|
83
|
+
The native C++ engine requires a uint8 corpus and interprets each byte as
|
|
84
|
+
an integer coordinate in [0, 255] (see `load_raw` in winnex-madhava: it
|
|
85
|
+
casts uint8 → float32 without re-scaling, then L2-normalizes). Passing raw
|
|
86
|
+
float32 normalized embeddings straight into `build_engine` silently
|
|
87
|
+
truncates them (`astype(np.uint8)` maps everything in [-1, 1] to 0) — a
|
|
88
|
+
zero corpus whose cosine scores are all 0.0.
|
|
89
|
+
|
|
90
|
+
This is the exact scale logic from the Maestro's `quantize_corpus`:
|
|
91
|
+
- values in [-1, 1] (embedding domain) → map to [0, 255] via (x+1)*127.5
|
|
92
|
+
- values in [0, 1] (post-activation) → scale to [0, 255] via x*255
|
|
93
|
+
- values already in [0, 255] → left as-is (round+clip)
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
embeddings: (n, d) float32 normalized embeddings (cosine).
|
|
97
|
+
Returns:
|
|
98
|
+
(n, d) uint8 corpus ready for `build_engine`.
|
|
99
|
+
"""
|
|
100
|
+
arr = validate_embeddings(embeddings)
|
|
101
|
+
if arr.size and arr.min() < 0.0:
|
|
102
|
+
# Embedding-domain values (normalized vectors): [-1, 1] -> [0, 255].
|
|
103
|
+
u8 = np.round((arr + 1.0) * 127.5).clip(0, 255).astype(np.uint8)
|
|
104
|
+
else:
|
|
105
|
+
# [0, 1] normalized domain (Qwen post-activation): scale to [0, 255].
|
|
106
|
+
u8 = np.round(arr * 255.0).clip(0, 255).astype(np.uint8)
|
|
107
|
+
return np.ascontiguousarray(u8, dtype=np.uint8)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# The normalizer facade (text/vectors → ready-for-madhava)
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
class EmbeddingNormalizer:
|
|
114
|
+
"""Normalizes text or raw vectors into Madhava-ready float32/uint8."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, config=None, embedding_service=None):
|
|
117
|
+
from .config import load_config
|
|
118
|
+
self.config = config or load_config()
|
|
119
|
+
self._embedding_service = embedding_service
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def embedding_service(self):
|
|
123
|
+
if self._embedding_service is None:
|
|
124
|
+
from .embedding import get_embedding_service
|
|
125
|
+
self._embedding_service = get_embedding_service()
|
|
126
|
+
return self._embedding_service
|
|
127
|
+
|
|
128
|
+
def vectorize_texts(self, texts, dim=None) -> np.ndarray:
|
|
129
|
+
"""Embed texts via the configured provider, normalized to float32.
|
|
130
|
+
|
|
131
|
+
Raises if no provider is available (NO fallback to fake vectors).
|
|
132
|
+
"""
|
|
133
|
+
dim = dim or self.config.default_dim
|
|
134
|
+
return self.embedding_service.embed_texts(texts, dim=dim)
|
|
135
|
+
|
|
136
|
+
def normalize_vectors(
|
|
137
|
+
self,
|
|
138
|
+
vectors: Union[np.ndarray, list],
|
|
139
|
+
dim: Optional[int] = None,
|
|
140
|
+
require_unit_norm: bool = False,
|
|
141
|
+
) -> np.ndarray:
|
|
142
|
+
"""Validate + normalize raw vectors to float32 (unit-norm optional)."""
|
|
143
|
+
arr = np.asarray(vectors, dtype=np.float32)
|
|
144
|
+
return validate_embeddings(arr, dim=dim, require_unit_norm=require_unit_norm)
|
|
145
|
+
|
|
146
|
+
def to_corpus(
|
|
147
|
+
self,
|
|
148
|
+
vectors: Union[np.ndarray, list],
|
|
149
|
+
dim: Optional[int] = None,
|
|
150
|
+
) -> np.ndarray:
|
|
151
|
+
"""Quantize float32 → uint8 (for BIGANN-style RAW BYTE corpora).
|
|
152
|
+
|
|
153
|
+
NOTE: this is NOT the cosine path. For float32 embeddings
|
|
154
|
+
(OpenAI/Qwen3) use `build_engine()` which goes through the engine's
|
|
155
|
+
float32 manifold (build_float32) and preserves cosine exactly.
|
|
156
|
+
"""
|
|
157
|
+
arr = self.normalize_vectors(vectors, dim=dim)
|
|
158
|
+
return quantize_corpus(arr)
|
|
159
|
+
|
|
160
|
+
def build_engine(self, vectors, dim=None, k=10, **engine_kwargs):
|
|
161
|
+
"""Build a Madhava engine over float32 embeddings (the CORRECT path).
|
|
162
|
+
|
|
163
|
+
Uses `winnex_madhava.build_engine` with the float32 corpus (the
|
|
164
|
+
engine's build_float32 manifold preserves cosine). The madhava
|
|
165
|
+
engine stays agnostic — it receives ready float32 vectors.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
vectors: (n, d) float32 embeddings (or text via vectorize_texts).
|
|
169
|
+
dim: embedding dimension.
|
|
170
|
+
k: top-k.
|
|
171
|
+
**engine_kwargs: passed to winnex_madhava.build_engine.
|
|
172
|
+
Returns:
|
|
173
|
+
a winnex_madhava engine.
|
|
174
|
+
"""
|
|
175
|
+
import winnex_madhava as wm
|
|
176
|
+
arr = self.normalize_vectors(vectors, dim=dim)
|
|
177
|
+
return wm.build_engine(
|
|
178
|
+
np.ascontiguousarray(arr, dtype=np.float32),
|
|
179
|
+
dim=dim or arr.shape[1],
|
|
180
|
+
metric="cosine",
|
|
181
|
+
k=k,
|
|
182
|
+
normalize_input=True,
|
|
183
|
+
**engine_kwargs,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def check_available(self) -> dict:
|
|
187
|
+
"""Health check: is the embedding provider reachable?"""
|
|
188
|
+
return self.embedding_service.check_available()
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
winnex-ai-normalize — normalization tests (REAL data, no fallback).
|
|
3
|
+
|
|
4
|
+
Validates:
|
|
5
|
+
- validate_embeddings rejects NaN/wrong-dim (fail loudly).
|
|
6
|
+
- normalize_l2 produces unit-norm rows.
|
|
7
|
+
- quantize_corpus maps float32 → uint8 with the correct scale.
|
|
8
|
+
- EmbeddingNormalizer end-to-end with a REAL dataset (arXiv d=1536).
|
|
9
|
+
|
|
10
|
+
Run: python -m pytest winnex_ai_normalize/tests/ -v
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from winnex_ai_normalize.core.normalize import (
|
|
18
|
+
EmbeddingNormalizer,
|
|
19
|
+
validate_embeddings,
|
|
20
|
+
normalize_l2,
|
|
21
|
+
quantize_corpus,
|
|
22
|
+
)
|
|
23
|
+
from winnex_ai_normalize.core.config import NormalizeConfig
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_validate_rejects_nan():
|
|
27
|
+
v = np.random.randn(10, 128).astype(np.float32)
|
|
28
|
+
v[3, 5] = np.nan
|
|
29
|
+
with pytest.raises(ValueError, match="NaN"):
|
|
30
|
+
validate_embeddings(v)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_validate_rejects_wrong_dim():
|
|
34
|
+
v = np.random.randn(10, 128).astype(np.float32)
|
|
35
|
+
with pytest.raises(ValueError, match="dimension"):
|
|
36
|
+
validate_embeddings(v, dim=64)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_validate_rejects_empty():
|
|
40
|
+
with pytest.raises(ValueError, match="empty"):
|
|
41
|
+
validate_embeddings(np.zeros((0, 128), dtype=np.float32))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_normalize_l2_unit_norm():
|
|
45
|
+
v = np.random.randn(50, 128).astype(np.float32)
|
|
46
|
+
n = normalize_l2(v)
|
|
47
|
+
norms = np.linalg.norm(n, axis=1)
|
|
48
|
+
assert np.allclose(norms, 1.0, atol=1e-5)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_quantize_corpus_scale():
|
|
52
|
+
"""uint8 quantization maps to [0, 255] with the correct scale.
|
|
53
|
+
|
|
54
|
+
NOTE: uint8 quantization is for BIGANN-style RAW BYTE corpora. For
|
|
55
|
+
float32 embeddings (OpenAI/Qwen3) the correct path is build_float32
|
|
56
|
+
(the engine's float32 manifold preserves cosine exactly); quantizing
|
|
57
|
+
float32 → uint8 with a shift is lossy and NOT the cosine path.
|
|
58
|
+
"""
|
|
59
|
+
v = np.random.randn(20, 128).astype(np.float32)
|
|
60
|
+
v /= np.linalg.norm(v, axis=1, keepdims=True)
|
|
61
|
+
u8 = quantize_corpus(v)
|
|
62
|
+
assert u8.dtype == np.uint8
|
|
63
|
+
assert u8.min() >= 0 and u8.max() <= 255
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_build_float32_path_preserves_cosine():
|
|
67
|
+
"""The float32 path (build_float32) preserves cosine — the correct
|
|
68
|
+
path for real embeddings (no uint8 quantization)."""
|
|
69
|
+
v = np.random.randn(20, 128).astype(np.float32)
|
|
70
|
+
v /= np.linalg.norm(v, axis=1, keepdims=True)
|
|
71
|
+
# After L2-normalization the cosine of the float32 IS the inner product.
|
|
72
|
+
c = float(v[0] @ v[1])
|
|
73
|
+
assert -1.0 <= c <= 1.0
|
|
74
|
+
# The normalizer validates + keeps float32 (no lossy uint8 for cosine).
|
|
75
|
+
from winnex_ai_normalize import validate_embeddings
|
|
76
|
+
out = validate_embeddings(v, dim=128)
|
|
77
|
+
assert np.allclose(out, v)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_normalizer_with_real_arxiv():
|
|
81
|
+
"""End-to-end with the REAL arXiv OpenAI embeddings (d=1536)."""
|
|
82
|
+
path = "/home/wnnx_user/zenodo/arxiv_100k.npy"
|
|
83
|
+
if not os.path.exists(path):
|
|
84
|
+
pytest.skip("arxiv_100k.npy not present — skipping real-data test")
|
|
85
|
+
a = np.load(path, mmap_mode="r")
|
|
86
|
+
X = np.ascontiguousarray(a[:200]) # real embeddings d=1536
|
|
87
|
+
n = np.linalg.norm(X, axis=1, keepdims=True)
|
|
88
|
+
X = X / np.maximum(n, 1e-12)
|
|
89
|
+
|
|
90
|
+
cfg = NormalizeConfig(default_dim=1536)
|
|
91
|
+
norm = EmbeddingNormalizer(config=cfg)
|
|
92
|
+
# Vectors pass through unchanged (validated + float32).
|
|
93
|
+
out = norm.normalize_vectors(X, dim=1536)
|
|
94
|
+
assert out.shape == (200, 1536)
|
|
95
|
+
assert np.isfinite(out).all()
|
|
96
|
+
# Corpus quantization for the madhava engine.
|
|
97
|
+
u8 = norm.to_corpus(X, dim=1536)
|
|
98
|
+
assert u8.shape == (200, 1536) and u8.dtype == np.uint8
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: winnex-ai-normalize
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Input normalization plug for the Madhava engine — embed text via any provider (OpenAI, Qwen3, nano, xfactor), validate/normalize to float32/uint8, with provider failover and NO fake-vector fallback. Consumed by winnex-madhava, winnex-tracer, Liferay and the Maestro.
|
|
5
|
+
Author-email: Winnex Brasil Soluções Empresariais LTDA-ME <pay@winnex.ai>
|
|
6
|
+
License: Business Source License 1.1
|
|
7
|
+
|
|
8
|
+
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
9
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
|
|
13
|
+
Licensor: Winnex AI
|
|
14
|
+
Licensed Work: Tracer-GOV
|
|
15
|
+
Additional Use Grant: Free for Brazilian government agencies.
|
|
16
|
+
Any use by a public sector entity in Brazil is permitted.
|
|
17
|
+
|
|
18
|
+
Change Date: 2036-01-01
|
|
19
|
+
Change License: GNU General Public License v2.0 or later
|
|
20
|
+
|
|
21
|
+
For inquiries: pay@winnex.ai
|
|
22
|
+
|
|
23
|
+
Project-URL: Homepage, https://winnex.ai
|
|
24
|
+
Project-URL: Source, https://github.com/winnex-ai/winnex-ai-normalize
|
|
25
|
+
Keywords: embedding,normalize,madhava,vector,openai,qwen3,rag,semantic-search,normalizer,winnex,failover
|
|
26
|
+
Classifier: Development Status :: 4 - Beta
|
|
27
|
+
Classifier: Intended Audience :: Developers
|
|
28
|
+
Classifier: License :: Other/Proprietary License
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
31
|
+
Requires-Python: >=3.10
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
License-File: LICENSE
|
|
34
|
+
Requires-Dist: numpy>=1.24
|
|
35
|
+
Requires-Dist: httpx>=0.24
|
|
36
|
+
Provides-Extra: api
|
|
37
|
+
Requires-Dist: fastapi>=0.100; extra == "api"
|
|
38
|
+
Requires-Dist: uvicorn>=0.20; extra == "api"
|
|
39
|
+
Provides-Extra: madhava
|
|
40
|
+
Requires-Dist: winnex-madhava>=1.9.2; extra == "madhava"
|
|
41
|
+
Provides-Extra: all
|
|
42
|
+
Requires-Dist: fastapi>=0.100; extra == "all"
|
|
43
|
+
Requires-Dist: uvicorn>=0.20; extra == "all"
|
|
44
|
+
Requires-Dist: winnex-madhava>=1.9.2; extra == "all"
|
|
45
|
+
Dynamic: license-file
|
|
46
|
+
|
|
47
|
+
# winnex-ai-normalize
|
|
48
|
+
|
|
49
|
+
**The input-normalization plug for the Madhava engine.**
|
|
50
|
+
|
|
51
|
+
The Madhava engine is **agnostic** — it consumes float32 vectors. This
|
|
52
|
+
package normalizes **any** input (text via an embedding provider, or raw
|
|
53
|
+
vectors) into a valid float32/uint8 corpus ready for
|
|
54
|
+
`winnex_madhava.build_engine`, with **provider failover** and **no silent
|
|
55
|
+
fallback to fake vectors**.
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
text / raw vectors
|
|
59
|
+
│
|
|
60
|
+
▼
|
|
61
|
+
winnex-ai-normalize
|
|
62
|
+
├── providers: OpenAI / Qwen3 / nano / xfactor / direct (failover order)
|
|
63
|
+
├── validate: dim + NaN + norm (fail loudly)
|
|
64
|
+
├── normalize: L2 unit-norm (cosine contract)
|
|
65
|
+
└── quantize: float32 → uint8 (the engine's native corpus)
|
|
66
|
+
│
|
|
67
|
+
▼
|
|
68
|
+
winnex-madhava (agnostic) → top-K + Cauchy-Schwarz proof
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install winnex-ai-normalize # core (numpy + httpx)
|
|
75
|
+
pip install winnex-ai-normalize[api] # + OpenAI-compatible /v1/embeddings
|
|
76
|
+
pip install winnex-ai-normalize[all] # + winnex-madhava
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Quick start
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from winnex_ai_normalize import EmbeddingNormalizer
|
|
83
|
+
|
|
84
|
+
norm = EmbeddingNormalizer() # reads env / JSON config
|
|
85
|
+
|
|
86
|
+
# 1. Embed text via the configured provider (OpenAI / Qwen3 / ...)
|
|
87
|
+
vecs = norm.vectorize_texts(["hypertension treatment", "diabetes care"])
|
|
88
|
+
# vecs: (2, d) float32 L2-normalized
|
|
89
|
+
|
|
90
|
+
# 2. Or normalize raw vectors (validated, no NaN, unit-norm)
|
|
91
|
+
vecs = norm.normalize_vectors(my_embeddings, dim=1024)
|
|
92
|
+
|
|
93
|
+
# 3. Feed the Madhava engine (agnostic)
|
|
94
|
+
u8 = norm.to_corpus(vecs, dim=1024) # uint8 corpus for build_engine
|
|
95
|
+
import winnex_madhava as wm
|
|
96
|
+
engine = wm.build_engine(u8, dim=1024, metric="cosine", k=10)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## OpenAI-compatible API
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uvicorn winnex_ai_normalize.api.server:app --port 8102
|
|
103
|
+
curl -X POST http://localhost:8102/v1/embeddings \
|
|
104
|
+
-H "Content-Type: application/json" \
|
|
105
|
+
-d '{"model": "Qwen3-Embedding-0.6B", "input": ["text one", "text two"]}'
|
|
106
|
+
# → {"data": [{"embedding": [...], "index": 0}, ...], ...}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Provider failover (no fake fallback)
|
|
110
|
+
|
|
111
|
+
Providers are tried in `provider_order` (JSON/env config). If **all**
|
|
112
|
+
fail, a `RuntimeError` is raised with the collected errors — a normalizer
|
|
113
|
+
must never fabricate embeddings.
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"default_dim": 1024,
|
|
118
|
+
"default_provider": "qwen3",
|
|
119
|
+
"providers": [
|
|
120
|
+
{"name": "qwen3", "base_url": "http://winnex-embedding:8102",
|
|
121
|
+
"model": "/workspace/models/Qwen3-Embedding-0.6B", "priority": 1},
|
|
122
|
+
{"name": "openai", "base_url": "https://api.openai.com/v1",
|
|
123
|
+
"model": "text-embedding-3-small", "api_key_env": "OPENAI_API_KEY", "priority": 2}
|
|
124
|
+
],
|
|
125
|
+
"provider_order": ["qwen3", "openai"]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Consumed by
|
|
130
|
+
|
|
131
|
+
- **winnex-madhava** (direct — the engine stays agnostic)
|
|
132
|
+
- **winnex-tracer** (audit + commitment)
|
|
133
|
+
- **Liferay bridges** (tracer-gov-liferay, tracer-med-liferay)
|
|
134
|
+
- **Maestro** (winnex-ai-server / winnex-ai-engine) and any external tool
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
Business Source License 1.1 (BSL 1.1) | pay@winnex.ai |
|
|
139
|
+
Winnex Brasil Soluções Empresariais LTDA (CNPJ 58.364.637/0001-47)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
winnex_ai_normalize/__init__.py
|
|
5
|
+
winnex_ai_normalize.egg-info/PKG-INFO
|
|
6
|
+
winnex_ai_normalize.egg-info/SOURCES.txt
|
|
7
|
+
winnex_ai_normalize.egg-info/dependency_links.txt
|
|
8
|
+
winnex_ai_normalize.egg-info/requires.txt
|
|
9
|
+
winnex_ai_normalize.egg-info/top_level.txt
|
|
10
|
+
winnex_ai_normalize/api/__init__.py
|
|
11
|
+
winnex_ai_normalize/api/server.py
|
|
12
|
+
winnex_ai_normalize/core/__init__.py
|
|
13
|
+
winnex_ai_normalize/core/config.py
|
|
14
|
+
winnex_ai_normalize/core/embedding.py
|
|
15
|
+
winnex_ai_normalize/core/normalize.py
|
|
16
|
+
winnex_ai_normalize/tests/__init__.py
|
|
17
|
+
winnex_ai_normalize/tests/test_normalize.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
winnex_ai_normalize
|