classer 0.0.3__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.
- classer-0.0.3/.gitignore +1 -0
- classer-0.0.3/PKG-INFO +162 -0
- classer-0.0.3/README.md +135 -0
- classer-0.0.3/pyproject.toml +47 -0
- classer-0.0.3/src/classer/__init__.py +69 -0
- classer-0.0.3/src/classer/client.py +186 -0
- classer-0.0.3/src/classer/exceptions.py +26 -0
- classer-0.0.3/src/classer/types.py +35 -0
- classer-0.0.3/tests/__init__.py +0 -0
- classer-0.0.3/tests/test_client.py +635 -0
classer-0.0.3/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sst.pyi
|
classer-0.0.3/PKG-INFO
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: classer
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: High-performance AI classification — <200ms latency, up to 100x cheaper, beats GPT-5 mini accuracy
|
|
5
|
+
Project-URL: Homepage, https://classer.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.classer.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/classer-ai/classer-python
|
|
8
|
+
Author: Classer.ai
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai,classification,llm,machine-learning,nlp,text-classification
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx>=0.25.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
exit
|
|
29
|
+
# [classer](https://classer.ai)
|
|
30
|
+
|
|
31
|
+
High-performance AI classification
|
|
32
|
+
|
|
33
|
+
<200ms latency · up to 100x cheaper · beats GPT-5 mini accuracy · self-calibrating accuracy · no prompt engineering
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install classer
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import classer
|
|
45
|
+
|
|
46
|
+
# Single-label classification
|
|
47
|
+
result = classer.classify(
|
|
48
|
+
text="I can't log in and need a password reset.",
|
|
49
|
+
labels=["billing", "technical_support", "sales", "spam"]
|
|
50
|
+
)
|
|
51
|
+
print(result.label) # "technical_support"
|
|
52
|
+
print(result.confidence) # 0.94
|
|
53
|
+
|
|
54
|
+
# With descriptions for better accuracy
|
|
55
|
+
lead = classer.classify(
|
|
56
|
+
text="We need a solution for 500 users, what's your enterprise pricing?",
|
|
57
|
+
labels=["hot", "warm", "cold"],
|
|
58
|
+
descriptions={
|
|
59
|
+
"hot": "Ready to buy, asking for pricing or demo",
|
|
60
|
+
"warm": "Interested but exploring options",
|
|
61
|
+
"cold": "Just browsing, no clear intent"
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
print(lead.label) # "hot"
|
|
65
|
+
|
|
66
|
+
# Multi-label tagging
|
|
67
|
+
result = classer.tag(
|
|
68
|
+
text="Breaking: Tech stocks surge amid AI boom",
|
|
69
|
+
labels=["politics", "technology", "finance", "sports"],
|
|
70
|
+
threshold=0.3
|
|
71
|
+
)
|
|
72
|
+
for t in result.labels:
|
|
73
|
+
print(f"{t.label}: {t.confidence}")
|
|
74
|
+
# technology: 0.92
|
|
75
|
+
# finance: 0.78
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Configuration
|
|
79
|
+
|
|
80
|
+
No API key is needed to get started. To unlock higher rate limits, get an API key from [classer.ai/api-keys](https://classer.ai/api-keys).
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
export CLASSER_API_KEY=your-api-key
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or configure programmatically:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from classer import ClasserClient
|
|
90
|
+
|
|
91
|
+
client = ClasserClient(
|
|
92
|
+
api_key="your-api-key"
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## API Reference
|
|
97
|
+
|
|
98
|
+
### `classify(text, labels=None, classifier=None, descriptions=None, speed=None, cache=None)`
|
|
99
|
+
|
|
100
|
+
Classify text into exactly one of the provided labels.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
result = classer.classify(
|
|
104
|
+
text="Text to classify",
|
|
105
|
+
labels=["label1", "label2"], # 1-200 possible labels
|
|
106
|
+
descriptions={"label1": "Description for better accuracy"},
|
|
107
|
+
speed="standard", # "standard" (default, <1s) or "fast" (<200ms)
|
|
108
|
+
cache=True # Set to False to bypass cache. Default: True
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
result.label # Selected label
|
|
112
|
+
result.confidence # 0-1 confidence score
|
|
113
|
+
result.tokens # Total tokens used
|
|
114
|
+
result.latency_ms # Processing time in ms
|
|
115
|
+
result.cached # Whether served from cache
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `tag(text, labels=None, classifier=None, descriptions=None, threshold=None, speed=None, cache=None)`
|
|
119
|
+
|
|
120
|
+
Multi-label tagging — returns all labels above a confidence threshold.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
result = classer.tag(
|
|
124
|
+
text="Text to tag",
|
|
125
|
+
labels=["label1", "label2"], # 1-200 possible labels
|
|
126
|
+
descriptions={"label1": "Description"},
|
|
127
|
+
threshold=0.3, # Default: 0.3
|
|
128
|
+
speed="standard", # "standard" (default, <1s) or "fast" (<200ms)
|
|
129
|
+
cache=True # Set to False to bypass cache. Default: True
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
for t in result.labels:
|
|
133
|
+
print(f"{t.label}: {t.confidence}")
|
|
134
|
+
|
|
135
|
+
result.tokens # Total tokens used
|
|
136
|
+
result.latency_ms # Processing time in ms
|
|
137
|
+
result.cached # Whether served from cache
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Error Handling
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from classer import ClasserError
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
result = classer.classify(text="hello", labels=["a", "b"])
|
|
147
|
+
except ClasserError as e:
|
|
148
|
+
print(e.status) # HTTP status code
|
|
149
|
+
print(e.detail) # Error detail from API
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Documentation
|
|
153
|
+
|
|
154
|
+
Full API reference and guides at [docs.classer.ai](https://docs.classer.ai).
|
|
155
|
+
|
|
156
|
+
## GitHub
|
|
157
|
+
|
|
158
|
+
[github.com/classer-ai/classer-python](https://github.com/classer-ai/classer-python)
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
classer-0.0.3/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
exit
|
|
2
|
+
# [classer](https://classer.ai)
|
|
3
|
+
|
|
4
|
+
High-performance AI classification
|
|
5
|
+
|
|
6
|
+
<200ms latency · up to 100x cheaper · beats GPT-5 mini accuracy · self-calibrating accuracy · no prompt engineering
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install classer
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import classer
|
|
18
|
+
|
|
19
|
+
# Single-label classification
|
|
20
|
+
result = classer.classify(
|
|
21
|
+
text="I can't log in and need a password reset.",
|
|
22
|
+
labels=["billing", "technical_support", "sales", "spam"]
|
|
23
|
+
)
|
|
24
|
+
print(result.label) # "technical_support"
|
|
25
|
+
print(result.confidence) # 0.94
|
|
26
|
+
|
|
27
|
+
# With descriptions for better accuracy
|
|
28
|
+
lead = classer.classify(
|
|
29
|
+
text="We need a solution for 500 users, what's your enterprise pricing?",
|
|
30
|
+
labels=["hot", "warm", "cold"],
|
|
31
|
+
descriptions={
|
|
32
|
+
"hot": "Ready to buy, asking for pricing or demo",
|
|
33
|
+
"warm": "Interested but exploring options",
|
|
34
|
+
"cold": "Just browsing, no clear intent"
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
print(lead.label) # "hot"
|
|
38
|
+
|
|
39
|
+
# Multi-label tagging
|
|
40
|
+
result = classer.tag(
|
|
41
|
+
text="Breaking: Tech stocks surge amid AI boom",
|
|
42
|
+
labels=["politics", "technology", "finance", "sports"],
|
|
43
|
+
threshold=0.3
|
|
44
|
+
)
|
|
45
|
+
for t in result.labels:
|
|
46
|
+
print(f"{t.label}: {t.confidence}")
|
|
47
|
+
# technology: 0.92
|
|
48
|
+
# finance: 0.78
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
No API key is needed to get started. To unlock higher rate limits, get an API key from [classer.ai/api-keys](https://classer.ai/api-keys).
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
export CLASSER_API_KEY=your-api-key
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or configure programmatically:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from classer import ClasserClient
|
|
63
|
+
|
|
64
|
+
client = ClasserClient(
|
|
65
|
+
api_key="your-api-key"
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## API Reference
|
|
70
|
+
|
|
71
|
+
### `classify(text, labels=None, classifier=None, descriptions=None, speed=None, cache=None)`
|
|
72
|
+
|
|
73
|
+
Classify text into exactly one of the provided labels.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
result = classer.classify(
|
|
77
|
+
text="Text to classify",
|
|
78
|
+
labels=["label1", "label2"], # 1-200 possible labels
|
|
79
|
+
descriptions={"label1": "Description for better accuracy"},
|
|
80
|
+
speed="standard", # "standard" (default, <1s) or "fast" (<200ms)
|
|
81
|
+
cache=True # Set to False to bypass cache. Default: True
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
result.label # Selected label
|
|
85
|
+
result.confidence # 0-1 confidence score
|
|
86
|
+
result.tokens # Total tokens used
|
|
87
|
+
result.latency_ms # Processing time in ms
|
|
88
|
+
result.cached # Whether served from cache
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `tag(text, labels=None, classifier=None, descriptions=None, threshold=None, speed=None, cache=None)`
|
|
92
|
+
|
|
93
|
+
Multi-label tagging — returns all labels above a confidence threshold.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
result = classer.tag(
|
|
97
|
+
text="Text to tag",
|
|
98
|
+
labels=["label1", "label2"], # 1-200 possible labels
|
|
99
|
+
descriptions={"label1": "Description"},
|
|
100
|
+
threshold=0.3, # Default: 0.3
|
|
101
|
+
speed="standard", # "standard" (default, <1s) or "fast" (<200ms)
|
|
102
|
+
cache=True # Set to False to bypass cache. Default: True
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
for t in result.labels:
|
|
106
|
+
print(f"{t.label}: {t.confidence}")
|
|
107
|
+
|
|
108
|
+
result.tokens # Total tokens used
|
|
109
|
+
result.latency_ms # Processing time in ms
|
|
110
|
+
result.cached # Whether served from cache
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Error Handling
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from classer import ClasserError
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
result = classer.classify(text="hello", labels=["a", "b"])
|
|
120
|
+
except ClasserError as e:
|
|
121
|
+
print(e.status) # HTTP status code
|
|
122
|
+
print(e.detail) # Error detail from API
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Documentation
|
|
126
|
+
|
|
127
|
+
Full API reference and guides at [docs.classer.ai](https://docs.classer.ai).
|
|
128
|
+
|
|
129
|
+
## GitHub
|
|
130
|
+
|
|
131
|
+
[github.com/classer-ai/classer-python](https://github.com/classer-ai/classer-python)
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "classer"
|
|
7
|
+
version = "0.0.3"
|
|
8
|
+
description = "High-performance AI classification — <200ms latency, up to 100x cheaper, beats GPT-5 mini accuracy"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Classer.ai" }]
|
|
12
|
+
keywords = ["ai", "classification", "nlp", "machine-learning", "text-classification", "llm"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
]
|
|
24
|
+
requires-python = ">=3.9"
|
|
25
|
+
dependencies = ["httpx>=0.25.0"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0", "ruff>=0.1.0"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://classer.ai"
|
|
32
|
+
Documentation = "https://docs.classer.ai"
|
|
33
|
+
Repository = "https://github.com/classer-ai/classer-python"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/classer"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 100
|
|
40
|
+
target-version = "py39"
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "I", "W"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
asyncio_mode = "auto"
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Classer SDK - Low-cost, fast AI classification API."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .client import ClasserClient
|
|
6
|
+
from .exceptions import ClasserError
|
|
7
|
+
from .types import (
|
|
8
|
+
ClassifyResponse,
|
|
9
|
+
TagLabel,
|
|
10
|
+
TagResponse,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ClasserClient",
|
|
15
|
+
"ClasserError",
|
|
16
|
+
"ClassifyResponse",
|
|
17
|
+
"TagLabel",
|
|
18
|
+
"TagResponse",
|
|
19
|
+
"classify",
|
|
20
|
+
"tag",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# Default client instance
|
|
24
|
+
_default_client: ClasserClient | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_default_client() -> ClasserClient:
|
|
28
|
+
global _default_client
|
|
29
|
+
if _default_client is None:
|
|
30
|
+
_default_client = ClasserClient()
|
|
31
|
+
return _default_client
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def classify(
|
|
35
|
+
text: str,
|
|
36
|
+
labels: Optional[list[str]] = None,
|
|
37
|
+
classifier: Optional[str] = None,
|
|
38
|
+
descriptions: Optional[dict[str, str]] = None,
|
|
39
|
+
speed: Optional[str] = None,
|
|
40
|
+
cache: Optional[bool] = None,
|
|
41
|
+
) -> ClassifyResponse:
|
|
42
|
+
"""Classify text into one of the provided labels (single-label).
|
|
43
|
+
|
|
44
|
+
See ClasserClient.classify for full parameter documentation.
|
|
45
|
+
"""
|
|
46
|
+
return _get_default_client().classify(
|
|
47
|
+
text, labels=labels, classifier=classifier,
|
|
48
|
+
descriptions=descriptions, speed=speed, cache=cache,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def tag(
|
|
53
|
+
text: str,
|
|
54
|
+
labels: Optional[list[str]] = None,
|
|
55
|
+
classifier: Optional[str] = None,
|
|
56
|
+
descriptions: Optional[dict[str, str]] = None,
|
|
57
|
+
threshold: Optional[float] = None,
|
|
58
|
+
speed: Optional[str] = None,
|
|
59
|
+
cache: Optional[bool] = None,
|
|
60
|
+
) -> TagResponse:
|
|
61
|
+
"""Tag text with multiple labels (multi-label).
|
|
62
|
+
|
|
63
|
+
See ClasserClient.tag for full parameter documentation.
|
|
64
|
+
"""
|
|
65
|
+
return _get_default_client().tag(
|
|
66
|
+
text, labels=labels, classifier=classifier,
|
|
67
|
+
descriptions=descriptions, threshold=threshold,
|
|
68
|
+
speed=speed, cache=cache,
|
|
69
|
+
)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Classer client implementation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .exceptions import ClasserError
|
|
9
|
+
from .types import (
|
|
10
|
+
ClassifyResponse,
|
|
11
|
+
TagLabel,
|
|
12
|
+
TagResponse,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClasserClient:
|
|
17
|
+
"""Client for the Classer API."""
|
|
18
|
+
|
|
19
|
+
BASE_URL = "https://api.classer.ai"
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
api_key: Optional[str] = None,
|
|
24
|
+
timeout: float = 30.0,
|
|
25
|
+
):
|
|
26
|
+
"""
|
|
27
|
+
Initialize the Classer client.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
api_key: API key for authentication. Falls back to CLASSER_API_KEY env var.
|
|
31
|
+
timeout: Request timeout in seconds.
|
|
32
|
+
"""
|
|
33
|
+
self.api_key = api_key or os.environ.get("CLASSER_API_KEY", "")
|
|
34
|
+
self.timeout = timeout
|
|
35
|
+
|
|
36
|
+
def _request(self, endpoint: str, body: dict) -> dict:
|
|
37
|
+
"""Make a POST request to the API."""
|
|
38
|
+
url = f"{self.BASE_URL}{endpoint}"
|
|
39
|
+
|
|
40
|
+
headers = {"Content-Type": "application/json"}
|
|
41
|
+
if self.api_key:
|
|
42
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
43
|
+
|
|
44
|
+
response = httpx.post(url, json=body, headers=headers, timeout=self.timeout)
|
|
45
|
+
|
|
46
|
+
if not response.is_success:
|
|
47
|
+
detail = None
|
|
48
|
+
try:
|
|
49
|
+
error_data = response.json()
|
|
50
|
+
detail = error_data.get("detail") if isinstance(error_data, dict) else None
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
detail = response.text[:200] if response.text else None
|
|
53
|
+
raise ClasserError(
|
|
54
|
+
f"Request failed with status {response.status_code}",
|
|
55
|
+
status=response.status_code,
|
|
56
|
+
detail=detail,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return response.json()
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _build_body(
|
|
63
|
+
text: str,
|
|
64
|
+
labels: Optional[list[str]] = None,
|
|
65
|
+
classifier: Optional[str] = None,
|
|
66
|
+
descriptions: Optional[dict[str, str]] = None,
|
|
67
|
+
speed: Optional[str] = None,
|
|
68
|
+
cache: Optional[bool] = None,
|
|
69
|
+
threshold: Optional[float] = None,
|
|
70
|
+
) -> dict:
|
|
71
|
+
"""Build a request body dict, omitting None/empty fields."""
|
|
72
|
+
body: dict = {"text": text}
|
|
73
|
+
if classifier:
|
|
74
|
+
body["classifier"] = classifier
|
|
75
|
+
if labels:
|
|
76
|
+
body["labels"] = labels
|
|
77
|
+
if descriptions:
|
|
78
|
+
body["descriptions"] = descriptions
|
|
79
|
+
if threshold is not None:
|
|
80
|
+
body["threshold"] = threshold
|
|
81
|
+
if speed:
|
|
82
|
+
body["speed"] = speed
|
|
83
|
+
if cache is not None:
|
|
84
|
+
body["cache"] = cache
|
|
85
|
+
return body
|
|
86
|
+
|
|
87
|
+
def classify(
|
|
88
|
+
self,
|
|
89
|
+
text: str,
|
|
90
|
+
labels: Optional[list[str]] = None,
|
|
91
|
+
classifier: Optional[str] = None,
|
|
92
|
+
descriptions: Optional[dict[str, str]] = None,
|
|
93
|
+
speed: Optional[str] = None,
|
|
94
|
+
cache: Optional[bool] = None,
|
|
95
|
+
) -> ClassifyResponse:
|
|
96
|
+
"""
|
|
97
|
+
Classify text into one of the provided labels (single-label).
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
text: Text to classify.
|
|
101
|
+
labels: List of possible labels (1-200).
|
|
102
|
+
classifier: Saved classifier name or "name@vN" reference.
|
|
103
|
+
descriptions: Maps label name to description for better accuracy.
|
|
104
|
+
speed: Speed tier — "standard" (default, <1s) or "fast" (<200ms).
|
|
105
|
+
cache: Set to False to bypass cache. Default: True.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
ClassifyResponse with label and confidence.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> result = classer.classify(
|
|
112
|
+
... text="I can't log in",
|
|
113
|
+
... labels=["billing", "technical_support", "sales"]
|
|
114
|
+
... )
|
|
115
|
+
>>> print(result.label) # "technical_support"
|
|
116
|
+
"""
|
|
117
|
+
body = self._build_body(
|
|
118
|
+
text, labels=labels, classifier=classifier,
|
|
119
|
+
descriptions=descriptions, speed=speed, cache=cache,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
data = self._request("/v1/classify", body)
|
|
123
|
+
|
|
124
|
+
return ClassifyResponse(
|
|
125
|
+
label=data.get("label"),
|
|
126
|
+
confidence=data.get("confidence"),
|
|
127
|
+
tokens=data.get("tokens", 0),
|
|
128
|
+
latency_ms=data.get("latency_ms", 0),
|
|
129
|
+
cached=data.get("cached", False),
|
|
130
|
+
public=data.get("public"),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def tag(
|
|
134
|
+
self,
|
|
135
|
+
text: str,
|
|
136
|
+
labels: Optional[list[str]] = None,
|
|
137
|
+
classifier: Optional[str] = None,
|
|
138
|
+
descriptions: Optional[dict[str, str]] = None,
|
|
139
|
+
threshold: Optional[float] = None,
|
|
140
|
+
speed: Optional[str] = None,
|
|
141
|
+
cache: Optional[bool] = None,
|
|
142
|
+
) -> TagResponse:
|
|
143
|
+
"""
|
|
144
|
+
Tag text with multiple labels that exceed a confidence threshold.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
text: Text to tag.
|
|
148
|
+
labels: List of possible labels (1-200).
|
|
149
|
+
classifier: Saved classifier name or "name@vN" reference.
|
|
150
|
+
descriptions: Maps label name to description for better accuracy.
|
|
151
|
+
threshold: Confidence threshold (0-1). Default: 0.3.
|
|
152
|
+
speed: Speed tier — "standard" (default, <1s) or "fast" (<200ms).
|
|
153
|
+
cache: Set to False to bypass cache. Default: True.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
TagResponse with labels list (each has label and confidence).
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
>>> result = classer.tag(
|
|
160
|
+
... text="Breaking: Tech stocks surge amid AI boom",
|
|
161
|
+
... labels=["politics", "technology", "finance", "sports"],
|
|
162
|
+
... threshold=0.3
|
|
163
|
+
... )
|
|
164
|
+
>>> for tag in result.labels:
|
|
165
|
+
... print(f"{tag.label}: {tag.confidence}")
|
|
166
|
+
"""
|
|
167
|
+
body = self._build_body(
|
|
168
|
+
text, labels=labels, classifier=classifier,
|
|
169
|
+
descriptions=descriptions, threshold=threshold,
|
|
170
|
+
speed=speed, cache=cache,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
data = self._request("/v1/tag", body)
|
|
174
|
+
|
|
175
|
+
tag_labels = [
|
|
176
|
+
TagLabel(label=item["label"], confidence=item["confidence"])
|
|
177
|
+
for item in data.get("labels") or []
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
return TagResponse(
|
|
181
|
+
labels=tag_labels,
|
|
182
|
+
tokens=data.get("tokens", 0),
|
|
183
|
+
latency_ms=data.get("latency_ms", 0),
|
|
184
|
+
cached=data.get("cached", False),
|
|
185
|
+
public=data.get("public"),
|
|
186
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Exception classes for the Classer SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClasserError(Exception):
|
|
7
|
+
"""Base exception for Classer SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
status: Optional[int] = None,
|
|
13
|
+
detail: Optional[str] = None,
|
|
14
|
+
):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status = status
|
|
18
|
+
self.detail = detail
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
parts = [self.message]
|
|
22
|
+
if self.status:
|
|
23
|
+
parts.append(f"(status: {self.status})")
|
|
24
|
+
if self.detail:
|
|
25
|
+
parts.append(f"- {self.detail}")
|
|
26
|
+
return " ".join(parts)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Type definitions for the Classer SDK."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ClassifyResponse:
|
|
9
|
+
"""Response from single-label classification."""
|
|
10
|
+
|
|
11
|
+
label: Optional[str] = None
|
|
12
|
+
confidence: Optional[float] = None
|
|
13
|
+
tokens: int = 0
|
|
14
|
+
latency_ms: float = 0.0
|
|
15
|
+
cached: bool = False
|
|
16
|
+
public: Optional[bool] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TagLabel:
|
|
21
|
+
"""Single label with confidence in a tag response."""
|
|
22
|
+
|
|
23
|
+
label: str
|
|
24
|
+
confidence: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class TagResponse:
|
|
29
|
+
"""Response from multi-label tagging."""
|
|
30
|
+
|
|
31
|
+
labels: list[TagLabel] = field(default_factory=list)
|
|
32
|
+
tokens: int = 0
|
|
33
|
+
latency_ms: float = 0.0
|
|
34
|
+
cached: bool = False
|
|
35
|
+
public: Optional[bool] = None
|
|
File without changes
|
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""Tests for the Classer SDK."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from classer import (
|
|
9
|
+
ClasserClient,
|
|
10
|
+
ClasserError,
|
|
11
|
+
ClassifyResponse,
|
|
12
|
+
TagResponse,
|
|
13
|
+
TagLabel,
|
|
14
|
+
classify,
|
|
15
|
+
tag,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestClasserClient:
|
|
20
|
+
"""Tests for ClasserClient class."""
|
|
21
|
+
|
|
22
|
+
def test_constructor_accepts_custom_config(self):
|
|
23
|
+
client = ClasserClient(
|
|
24
|
+
api_key="test-key",
|
|
25
|
+
)
|
|
26
|
+
assert client.api_key == "test-key"
|
|
27
|
+
|
|
28
|
+
def test_constructor_reads_api_key_from_environment(self):
|
|
29
|
+
with patch.dict(os.environ, {"CLASSER_API_KEY": "env-api-key"}):
|
|
30
|
+
client = ClasserClient()
|
|
31
|
+
assert client.api_key == "env-api-key"
|
|
32
|
+
|
|
33
|
+
def test_constructor_explicit_api_key_overrides_env(self):
|
|
34
|
+
with patch.dict(os.environ, {"CLASSER_API_KEY": "env-key"}):
|
|
35
|
+
client = ClasserClient(api_key="explicit-key")
|
|
36
|
+
assert client.api_key == "explicit-key"
|
|
37
|
+
|
|
38
|
+
def test_constructor_default_timeout(self):
|
|
39
|
+
client = ClasserClient()
|
|
40
|
+
assert client.timeout == 30.0
|
|
41
|
+
|
|
42
|
+
def test_constructor_custom_timeout(self):
|
|
43
|
+
client = ClasserClient(timeout=10.0)
|
|
44
|
+
assert client.timeout == 10.0
|
|
45
|
+
|
|
46
|
+
@patch("classer.client.httpx.post")
|
|
47
|
+
def test_passes_timeout_to_httpx(self, mock_post):
|
|
48
|
+
mock_response = MagicMock()
|
|
49
|
+
mock_response.is_success = True
|
|
50
|
+
mock_response.json.return_value = {
|
|
51
|
+
"label": "a",
|
|
52
|
+
"confidence": 0.9,
|
|
53
|
+
"latency_ms": 30,
|
|
54
|
+
}
|
|
55
|
+
mock_post.return_value = mock_response
|
|
56
|
+
|
|
57
|
+
client = ClasserClient(timeout=5.0)
|
|
58
|
+
client.classify(text="test", labels=["a", "b"])
|
|
59
|
+
|
|
60
|
+
call_args = mock_post.call_args
|
|
61
|
+
assert call_args[1]["timeout"] == 5.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestClassify:
|
|
65
|
+
"""Tests for classify method."""
|
|
66
|
+
|
|
67
|
+
@patch("classer.client.httpx.post")
|
|
68
|
+
def test_classify_text_successfully(self, mock_post):
|
|
69
|
+
mock_response = MagicMock()
|
|
70
|
+
mock_response.is_success = True
|
|
71
|
+
mock_response.json.return_value = {
|
|
72
|
+
"label": "technical_support",
|
|
73
|
+
"confidence": 0.94,
|
|
74
|
+
"tokens": 101,
|
|
75
|
+
"latency_ms": 45,
|
|
76
|
+
"cached": False,
|
|
77
|
+
}
|
|
78
|
+
mock_post.return_value = mock_response
|
|
79
|
+
|
|
80
|
+
client = ClasserClient(api_key="test-key")
|
|
81
|
+
result = client.classify(
|
|
82
|
+
text="I can't log in",
|
|
83
|
+
labels=["billing", "technical_support", "sales"],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assert isinstance(result, ClassifyResponse)
|
|
87
|
+
assert result.label == "technical_support"
|
|
88
|
+
assert result.confidence == 0.94
|
|
89
|
+
assert result.tokens == 101
|
|
90
|
+
assert result.latency_ms == 45
|
|
91
|
+
assert result.cached is False
|
|
92
|
+
|
|
93
|
+
mock_post.assert_called_once()
|
|
94
|
+
call_args = mock_post.call_args
|
|
95
|
+
assert call_args[0][0] == "https://api.classer.ai/v1/classify"
|
|
96
|
+
assert call_args[1]["headers"]["Authorization"] == "Bearer test-key"
|
|
97
|
+
|
|
98
|
+
@patch("classer.client.httpx.post")
|
|
99
|
+
def test_classify_includes_descriptions(self, mock_post):
|
|
100
|
+
mock_response = MagicMock()
|
|
101
|
+
mock_response.is_success = True
|
|
102
|
+
mock_response.json.return_value = {
|
|
103
|
+
"label": "hot",
|
|
104
|
+
"confidence": 0.92,
|
|
105
|
+
"latency_ms": 38,
|
|
106
|
+
}
|
|
107
|
+
mock_post.return_value = mock_response
|
|
108
|
+
|
|
109
|
+
client = ClasserClient(api_key="test-key")
|
|
110
|
+
client.classify(
|
|
111
|
+
text="I need enterprise pricing for 500 users",
|
|
112
|
+
labels=["hot", "warm", "cold"],
|
|
113
|
+
descriptions={
|
|
114
|
+
"hot": "Ready to buy",
|
|
115
|
+
"warm": "Interested but exploring",
|
|
116
|
+
"cold": "Just browsing",
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
call_args = mock_post.call_args
|
|
121
|
+
body = call_args[1]["json"]
|
|
122
|
+
assert body["descriptions"] == {
|
|
123
|
+
"hot": "Ready to buy",
|
|
124
|
+
"warm": "Interested but exploring",
|
|
125
|
+
"cold": "Just browsing",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@patch("classer.client.httpx.post")
|
|
129
|
+
def test_classify_handles_api_errors(self, mock_post):
|
|
130
|
+
mock_response = MagicMock()
|
|
131
|
+
mock_response.is_success = False
|
|
132
|
+
mock_response.status_code = 400
|
|
133
|
+
mock_response.json.return_value = {"detail": "labels cannot be empty"}
|
|
134
|
+
mock_post.return_value = mock_response
|
|
135
|
+
|
|
136
|
+
client = ClasserClient(api_key="test-key")
|
|
137
|
+
|
|
138
|
+
with pytest.raises(ClasserError) as exc_info:
|
|
139
|
+
client.classify(text="test", labels=[])
|
|
140
|
+
|
|
141
|
+
assert exc_info.value.status == 400
|
|
142
|
+
assert exc_info.value.detail == "labels cannot be empty"
|
|
143
|
+
|
|
144
|
+
@patch("classer.client.httpx.post")
|
|
145
|
+
def test_classify_handles_network_errors(self, mock_post):
|
|
146
|
+
mock_post.side_effect = Exception("Network error")
|
|
147
|
+
|
|
148
|
+
client = ClasserClient(api_key="test-key")
|
|
149
|
+
|
|
150
|
+
with pytest.raises(Exception, match="Network error"):
|
|
151
|
+
client.classify(text="test", labels=["a", "b"])
|
|
152
|
+
|
|
153
|
+
@patch("classer.client.httpx.post")
|
|
154
|
+
def test_classify_does_not_send_mode(self, mock_post):
|
|
155
|
+
"""classify() should NOT send mode in the request body."""
|
|
156
|
+
mock_response = MagicMock()
|
|
157
|
+
mock_response.is_success = True
|
|
158
|
+
mock_response.json.return_value = {
|
|
159
|
+
"label": "a",
|
|
160
|
+
"confidence": 0.9,
|
|
161
|
+
"latency_ms": 30,
|
|
162
|
+
}
|
|
163
|
+
mock_post.return_value = mock_response
|
|
164
|
+
|
|
165
|
+
client = ClasserClient(api_key="test-key")
|
|
166
|
+
client.classify(text="test", labels=["a", "b"])
|
|
167
|
+
|
|
168
|
+
call_args = mock_post.call_args
|
|
169
|
+
body = call_args[1]["json"]
|
|
170
|
+
assert "mode" not in body
|
|
171
|
+
assert "threshold" not in body
|
|
172
|
+
|
|
173
|
+
@patch("classer.client.httpx.post")
|
|
174
|
+
def test_classify_with_classifier_param(self, mock_post):
|
|
175
|
+
"""classify() sends classifier instead of labels when provided."""
|
|
176
|
+
mock_response = MagicMock()
|
|
177
|
+
mock_response.is_success = True
|
|
178
|
+
mock_response.json.return_value = {
|
|
179
|
+
"label": "billing",
|
|
180
|
+
"confidence": 0.88,
|
|
181
|
+
"latency_ms": 42,
|
|
182
|
+
}
|
|
183
|
+
mock_post.return_value = mock_response
|
|
184
|
+
|
|
185
|
+
client = ClasserClient(api_key="test-key")
|
|
186
|
+
result = client.classify(text="test", classifier="support-tickets@v2")
|
|
187
|
+
|
|
188
|
+
call_args = mock_post.call_args
|
|
189
|
+
body = call_args[1]["json"]
|
|
190
|
+
assert body["classifier"] == "support-tickets@v2"
|
|
191
|
+
assert "labels" not in body
|
|
192
|
+
assert result.label == "billing"
|
|
193
|
+
|
|
194
|
+
@patch("classer.client.httpx.post")
|
|
195
|
+
def test_classify_omits_none_optional_fields(self, mock_post):
|
|
196
|
+
"""Optional params that are None should not appear in the request body."""
|
|
197
|
+
mock_response = MagicMock()
|
|
198
|
+
mock_response.is_success = True
|
|
199
|
+
mock_response.json.return_value = {
|
|
200
|
+
"label": "a",
|
|
201
|
+
"confidence": 0.9,
|
|
202
|
+
"latency_ms": 30,
|
|
203
|
+
}
|
|
204
|
+
mock_post.return_value = mock_response
|
|
205
|
+
|
|
206
|
+
client = ClasserClient(api_key="test-key")
|
|
207
|
+
client.classify(text="test", labels=["a", "b"])
|
|
208
|
+
|
|
209
|
+
call_args = mock_post.call_args
|
|
210
|
+
body = call_args[1]["json"]
|
|
211
|
+
assert body == {"text": "test", "labels": ["a", "b"]}
|
|
212
|
+
|
|
213
|
+
@patch("classer.client.httpx.post")
|
|
214
|
+
def test_classify_defaults_when_fields_missing(self, mock_post):
|
|
215
|
+
"""tokens and cached should default when absent from response."""
|
|
216
|
+
mock_response = MagicMock()
|
|
217
|
+
mock_response.is_success = True
|
|
218
|
+
mock_response.json.return_value = {
|
|
219
|
+
"label": "a",
|
|
220
|
+
"confidence": 0.9,
|
|
221
|
+
"latency_ms": 30,
|
|
222
|
+
}
|
|
223
|
+
mock_post.return_value = mock_response
|
|
224
|
+
|
|
225
|
+
client = ClasserClient(api_key="test-key")
|
|
226
|
+
result = client.classify(text="test", labels=["a", "b"])
|
|
227
|
+
|
|
228
|
+
assert result.tokens == 0
|
|
229
|
+
assert result.cached is False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class TestTag:
|
|
233
|
+
"""Tests for tag method."""
|
|
234
|
+
|
|
235
|
+
@patch("classer.client.httpx.post")
|
|
236
|
+
def test_tag_with_multiple_labels(self, mock_post):
|
|
237
|
+
mock_response = MagicMock()
|
|
238
|
+
mock_response.is_success = True
|
|
239
|
+
mock_response.json.return_value = {
|
|
240
|
+
"labels": [
|
|
241
|
+
{"label": "technology", "confidence": 0.65},
|
|
242
|
+
{"label": "finance", "confidence": 0.42},
|
|
243
|
+
],
|
|
244
|
+
"tokens": 200,
|
|
245
|
+
"latency_ms": 52,
|
|
246
|
+
"cached": False,
|
|
247
|
+
}
|
|
248
|
+
mock_post.return_value = mock_response
|
|
249
|
+
|
|
250
|
+
client = ClasserClient(api_key="test-key")
|
|
251
|
+
result = client.tag(
|
|
252
|
+
text="Tech stocks surge amid AI boom",
|
|
253
|
+
labels=["politics", "technology", "finance", "sports"],
|
|
254
|
+
threshold=0.3,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
assert isinstance(result, TagResponse)
|
|
258
|
+
assert len(result.labels) == 2
|
|
259
|
+
assert result.labels[0].label == "technology"
|
|
260
|
+
assert result.labels[0].confidence == 0.65
|
|
261
|
+
assert result.labels[1].label == "finance"
|
|
262
|
+
assert result.tokens == 200
|
|
263
|
+
assert result.cached is False
|
|
264
|
+
|
|
265
|
+
call_args = mock_post.call_args
|
|
266
|
+
assert call_args[0][0] == "https://api.classer.ai/v1/tag"
|
|
267
|
+
|
|
268
|
+
@patch("classer.client.httpx.post")
|
|
269
|
+
def test_tag_sends_threshold(self, mock_post):
|
|
270
|
+
mock_response = MagicMock()
|
|
271
|
+
mock_response.is_success = True
|
|
272
|
+
mock_response.json.return_value = {
|
|
273
|
+
"labels": [{"label": "technology", "confidence": 0.85}],
|
|
274
|
+
"latency_ms": 48,
|
|
275
|
+
}
|
|
276
|
+
mock_post.return_value = mock_response
|
|
277
|
+
|
|
278
|
+
client = ClasserClient(api_key="test-key")
|
|
279
|
+
client.tag(
|
|
280
|
+
text="AI is transforming industries",
|
|
281
|
+
labels=["technology", "sports"],
|
|
282
|
+
threshold=0.5,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
call_args = mock_post.call_args
|
|
286
|
+
body = call_args[1]["json"]
|
|
287
|
+
assert body["threshold"] == 0.5
|
|
288
|
+
|
|
289
|
+
@patch("classer.client.httpx.post")
|
|
290
|
+
def test_tag_returns_empty_when_nothing_matches(self, mock_post):
|
|
291
|
+
mock_response = MagicMock()
|
|
292
|
+
mock_response.is_success = True
|
|
293
|
+
mock_response.json.return_value = {
|
|
294
|
+
"labels": [],
|
|
295
|
+
"latency_ms": 35,
|
|
296
|
+
}
|
|
297
|
+
mock_post.return_value = mock_response
|
|
298
|
+
|
|
299
|
+
client = ClasserClient(api_key="test-key")
|
|
300
|
+
result = client.tag(
|
|
301
|
+
text="Random unrelated text",
|
|
302
|
+
labels=["sports", "politics"],
|
|
303
|
+
threshold=0.9,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
assert result.labels == []
|
|
307
|
+
|
|
308
|
+
@patch("classer.client.httpx.post")
|
|
309
|
+
def test_tag_returns_empty_when_labels_is_null(self, mock_post):
|
|
310
|
+
"""API may return null instead of empty array."""
|
|
311
|
+
mock_response = MagicMock()
|
|
312
|
+
mock_response.is_success = True
|
|
313
|
+
mock_response.json.return_value = {
|
|
314
|
+
"labels": None,
|
|
315
|
+
"latency_ms": 35,
|
|
316
|
+
}
|
|
317
|
+
mock_post.return_value = mock_response
|
|
318
|
+
|
|
319
|
+
client = ClasserClient(api_key="test-key")
|
|
320
|
+
result = client.tag(text="test", labels=["a", "b"])
|
|
321
|
+
|
|
322
|
+
assert result.labels == []
|
|
323
|
+
|
|
324
|
+
@patch("classer.client.httpx.post")
|
|
325
|
+
def test_tag_does_not_send_mode(self, mock_post):
|
|
326
|
+
"""tag() should NOT send mode in the request body."""
|
|
327
|
+
mock_response = MagicMock()
|
|
328
|
+
mock_response.is_success = True
|
|
329
|
+
mock_response.json.return_value = {
|
|
330
|
+
"labels": [{"label": "a", "confidence": 0.8}],
|
|
331
|
+
"latency_ms": 30,
|
|
332
|
+
}
|
|
333
|
+
mock_post.return_value = mock_response
|
|
334
|
+
|
|
335
|
+
client = ClasserClient(api_key="test-key")
|
|
336
|
+
client.tag(text="test", labels=["a", "b"])
|
|
337
|
+
|
|
338
|
+
call_args = mock_post.call_args
|
|
339
|
+
body = call_args[1]["json"]
|
|
340
|
+
assert "mode" not in body
|
|
341
|
+
|
|
342
|
+
@patch("classer.client.httpx.post")
|
|
343
|
+
def test_tag_omits_threshold_when_not_provided(self, mock_post):
|
|
344
|
+
mock_response = MagicMock()
|
|
345
|
+
mock_response.is_success = True
|
|
346
|
+
mock_response.json.return_value = {
|
|
347
|
+
"labels": [{"label": "a", "confidence": 0.8}],
|
|
348
|
+
"latency_ms": 30,
|
|
349
|
+
}
|
|
350
|
+
mock_post.return_value = mock_response
|
|
351
|
+
|
|
352
|
+
client = ClasserClient(api_key="test-key")
|
|
353
|
+
client.tag(text="test", labels=["a", "b"])
|
|
354
|
+
|
|
355
|
+
call_args = mock_post.call_args
|
|
356
|
+
body = call_args[1]["json"]
|
|
357
|
+
assert "threshold" not in body
|
|
358
|
+
|
|
359
|
+
@patch("classer.client.httpx.post")
|
|
360
|
+
def test_tag_with_classifier_param(self, mock_post):
|
|
361
|
+
mock_response = MagicMock()
|
|
362
|
+
mock_response.is_success = True
|
|
363
|
+
mock_response.json.return_value = {
|
|
364
|
+
"labels": [{"label": "urgent", "confidence": 0.91}],
|
|
365
|
+
"latency_ms": 55,
|
|
366
|
+
}
|
|
367
|
+
mock_post.return_value = mock_response
|
|
368
|
+
|
|
369
|
+
client = ClasserClient(api_key="test-key")
|
|
370
|
+
result = client.tag(text="test", classifier="priority-tagger")
|
|
371
|
+
|
|
372
|
+
call_args = mock_post.call_args
|
|
373
|
+
body = call_args[1]["json"]
|
|
374
|
+
assert body["classifier"] == "priority-tagger"
|
|
375
|
+
assert "labels" not in body
|
|
376
|
+
assert result.labels[0].label == "urgent"
|
|
377
|
+
|
|
378
|
+
@patch("classer.client.httpx.post")
|
|
379
|
+
def test_tag_with_descriptions(self, mock_post):
|
|
380
|
+
mock_response = MagicMock()
|
|
381
|
+
mock_response.is_success = True
|
|
382
|
+
mock_response.json.return_value = {
|
|
383
|
+
"labels": [{"label": "tech", "confidence": 0.85}],
|
|
384
|
+
"latency_ms": 40,
|
|
385
|
+
}
|
|
386
|
+
mock_post.return_value = mock_response
|
|
387
|
+
|
|
388
|
+
client = ClasserClient(api_key="test-key")
|
|
389
|
+
client.tag(
|
|
390
|
+
text="test",
|
|
391
|
+
labels=["tech", "sports"],
|
|
392
|
+
descriptions={"tech": "Technology news", "sports": "Sports news"},
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
call_args = mock_post.call_args
|
|
396
|
+
body = call_args[1]["json"]
|
|
397
|
+
assert body["descriptions"] == {
|
|
398
|
+
"tech": "Technology news",
|
|
399
|
+
"sports": "Sports news",
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
@patch("classer.client.httpx.post")
|
|
403
|
+
def test_tag_handles_api_errors(self, mock_post):
|
|
404
|
+
mock_response = MagicMock()
|
|
405
|
+
mock_response.is_success = False
|
|
406
|
+
mock_response.status_code = 422
|
|
407
|
+
mock_response.json.return_value = {"detail": "At least 2 labels required"}
|
|
408
|
+
mock_post.return_value = mock_response
|
|
409
|
+
|
|
410
|
+
client = ClasserClient(api_key="test-key")
|
|
411
|
+
|
|
412
|
+
with pytest.raises(ClasserError) as exc_info:
|
|
413
|
+
client.tag(text="test", labels=["only_one"])
|
|
414
|
+
|
|
415
|
+
assert exc_info.value.status == 422
|
|
416
|
+
assert exc_info.value.detail == "At least 2 labels required"
|
|
417
|
+
|
|
418
|
+
@patch("classer.client.httpx.post")
|
|
419
|
+
def test_tag_defaults_when_fields_missing(self, mock_post):
|
|
420
|
+
"""tokens and cached should default when absent from response."""
|
|
421
|
+
mock_response = MagicMock()
|
|
422
|
+
mock_response.is_success = True
|
|
423
|
+
mock_response.json.return_value = {
|
|
424
|
+
"labels": [{"label": "a", "confidence": 0.8}],
|
|
425
|
+
"latency_ms": 50,
|
|
426
|
+
}
|
|
427
|
+
mock_post.return_value = mock_response
|
|
428
|
+
|
|
429
|
+
client = ClasserClient(api_key="test-key")
|
|
430
|
+
result = client.tag(text="test", labels=["a", "b"])
|
|
431
|
+
|
|
432
|
+
assert result.tokens == 0
|
|
433
|
+
assert result.cached is False
|
|
434
|
+
|
|
435
|
+
@patch("classer.client.httpx.post")
|
|
436
|
+
def test_tag_latency_ms(self, mock_post):
|
|
437
|
+
mock_response = MagicMock()
|
|
438
|
+
mock_response.is_success = True
|
|
439
|
+
mock_response.json.return_value = {
|
|
440
|
+
"labels": [],
|
|
441
|
+
"latency_ms": 203,
|
|
442
|
+
}
|
|
443
|
+
mock_post.return_value = mock_response
|
|
444
|
+
|
|
445
|
+
client = ClasserClient(api_key="test-key")
|
|
446
|
+
result = client.tag(text="test", labels=["a", "b"])
|
|
447
|
+
|
|
448
|
+
assert result.latency_ms == 203
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class TestDefaultExports:
|
|
452
|
+
"""Tests for module-level convenience functions."""
|
|
453
|
+
|
|
454
|
+
@patch("classer.client.httpx.post")
|
|
455
|
+
def test_classify_function_works(self, mock_post):
|
|
456
|
+
mock_response = MagicMock()
|
|
457
|
+
mock_response.is_success = True
|
|
458
|
+
mock_response.json.return_value = {
|
|
459
|
+
"label": "greeting",
|
|
460
|
+
"confidence": 0.95,
|
|
461
|
+
"latency_ms": 30,
|
|
462
|
+
}
|
|
463
|
+
mock_post.return_value = mock_response
|
|
464
|
+
|
|
465
|
+
result = classify(
|
|
466
|
+
text="Hello there!",
|
|
467
|
+
labels=["greeting", "question", "complaint"],
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
assert result.label == "greeting"
|
|
471
|
+
|
|
472
|
+
@patch("classer.client.httpx.post")
|
|
473
|
+
def test_tag_function_works(self, mock_post):
|
|
474
|
+
mock_response = MagicMock()
|
|
475
|
+
mock_response.is_success = True
|
|
476
|
+
mock_response.json.return_value = {
|
|
477
|
+
"labels": [
|
|
478
|
+
{"label": "news", "confidence": 0.8},
|
|
479
|
+
{"label": "technology", "confidence": 0.6},
|
|
480
|
+
],
|
|
481
|
+
"latency_ms": 45,
|
|
482
|
+
}
|
|
483
|
+
mock_post.return_value = mock_response
|
|
484
|
+
|
|
485
|
+
result = tag(
|
|
486
|
+
text="Apple announces new iPhone",
|
|
487
|
+
labels=["news", "technology", "sports"],
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
assert len(result.labels) == 2
|
|
491
|
+
assert result.labels[0].label == "news"
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class TestClasserError:
|
|
495
|
+
"""Tests for ClasserError exception."""
|
|
496
|
+
|
|
497
|
+
@patch("classer.client.httpx.post")
|
|
498
|
+
def test_error_contains_status_and_detail(self, mock_post):
|
|
499
|
+
mock_response = MagicMock()
|
|
500
|
+
mock_response.is_success = False
|
|
501
|
+
mock_response.status_code = 422
|
|
502
|
+
mock_response.json.return_value = {"detail": "Validation error"}
|
|
503
|
+
mock_post.return_value = mock_response
|
|
504
|
+
|
|
505
|
+
client = ClasserClient(api_key="test-key")
|
|
506
|
+
|
|
507
|
+
with pytest.raises(ClasserError) as exc_info:
|
|
508
|
+
client.classify(text="", labels=["a"])
|
|
509
|
+
|
|
510
|
+
assert exc_info.value.status == 422
|
|
511
|
+
assert exc_info.value.detail == "Validation error"
|
|
512
|
+
|
|
513
|
+
@patch("classer.client.httpx.post")
|
|
514
|
+
def test_error_handles_non_json_responses(self, mock_post):
|
|
515
|
+
mock_response = MagicMock()
|
|
516
|
+
mock_response.is_success = False
|
|
517
|
+
mock_response.status_code = 500
|
|
518
|
+
mock_response.text = ""
|
|
519
|
+
mock_response.json.side_effect = ValueError("Invalid JSON")
|
|
520
|
+
mock_post.return_value = mock_response
|
|
521
|
+
|
|
522
|
+
client = ClasserClient(api_key="test-key")
|
|
523
|
+
|
|
524
|
+
with pytest.raises(ClasserError) as exc_info:
|
|
525
|
+
client.classify(text="test", labels=["a", "b"])
|
|
526
|
+
|
|
527
|
+
assert exc_info.value.status == 500
|
|
528
|
+
assert exc_info.value.detail is None
|
|
529
|
+
|
|
530
|
+
def test_error_str_includes_status_and_detail(self):
|
|
531
|
+
err = ClasserError("Request failed", status=429, detail="Rate limit exceeded")
|
|
532
|
+
assert "429" in str(err)
|
|
533
|
+
assert "Rate limit exceeded" in str(err)
|
|
534
|
+
|
|
535
|
+
def test_error_str_without_detail(self):
|
|
536
|
+
err = ClasserError("Request failed", status=500)
|
|
537
|
+
assert "500" in str(err)
|
|
538
|
+
assert str(err) == "Request failed (status: 500)"
|
|
539
|
+
|
|
540
|
+
def test_error_str_without_status(self):
|
|
541
|
+
err = ClasserError("Something went wrong")
|
|
542
|
+
assert str(err) == "Something went wrong"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class TestRequestHeaders:
|
|
546
|
+
"""Tests for request header handling."""
|
|
547
|
+
|
|
548
|
+
@patch("classer.client.httpx.post")
|
|
549
|
+
def test_includes_authorization_header_when_api_key_set(self, mock_post):
|
|
550
|
+
mock_response = MagicMock()
|
|
551
|
+
mock_response.is_success = True
|
|
552
|
+
mock_response.json.return_value = {
|
|
553
|
+
"label": "a",
|
|
554
|
+
"confidence": 0.9,
|
|
555
|
+
"latency_ms": 30,
|
|
556
|
+
}
|
|
557
|
+
mock_post.return_value = mock_response
|
|
558
|
+
|
|
559
|
+
client = ClasserClient(api_key="my-secret-key")
|
|
560
|
+
client.classify(text="test", labels=["a", "b"])
|
|
561
|
+
|
|
562
|
+
call_args = mock_post.call_args
|
|
563
|
+
assert call_args[1]["headers"]["Authorization"] == "Bearer my-secret-key"
|
|
564
|
+
|
|
565
|
+
@patch("classer.client.httpx.post")
|
|
566
|
+
def test_no_authorization_header_when_api_key_empty(self, mock_post):
|
|
567
|
+
mock_response = MagicMock()
|
|
568
|
+
mock_response.is_success = True
|
|
569
|
+
mock_response.json.return_value = {
|
|
570
|
+
"label": "a",
|
|
571
|
+
"confidence": 0.9,
|
|
572
|
+
"latency_ms": 30,
|
|
573
|
+
}
|
|
574
|
+
mock_post.return_value = mock_response
|
|
575
|
+
|
|
576
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
577
|
+
client = ClasserClient(api_key="")
|
|
578
|
+
client.classify(text="test", labels=["a", "b"])
|
|
579
|
+
|
|
580
|
+
call_args = mock_post.call_args
|
|
581
|
+
assert "Authorization" not in call_args[1]["headers"]
|
|
582
|
+
|
|
583
|
+
@patch("classer.client.httpx.post")
|
|
584
|
+
def test_always_includes_content_type_header(self, mock_post):
|
|
585
|
+
mock_response = MagicMock()
|
|
586
|
+
mock_response.is_success = True
|
|
587
|
+
mock_response.json.return_value = {
|
|
588
|
+
"label": "a",
|
|
589
|
+
"confidence": 0.9,
|
|
590
|
+
"latency_ms": 30,
|
|
591
|
+
}
|
|
592
|
+
mock_post.return_value = mock_response
|
|
593
|
+
|
|
594
|
+
client = ClasserClient()
|
|
595
|
+
client.classify(text="test", labels=["a", "b"])
|
|
596
|
+
|
|
597
|
+
call_args = mock_post.call_args
|
|
598
|
+
assert call_args[1]["headers"]["Content-Type"] == "application/json"
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class TestEndpoints:
|
|
602
|
+
"""Tests for correct endpoint URLs."""
|
|
603
|
+
|
|
604
|
+
@patch("classer.client.httpx.post")
|
|
605
|
+
def test_classify_uses_correct_endpoint(self, mock_post):
|
|
606
|
+
mock_response = MagicMock()
|
|
607
|
+
mock_response.is_success = True
|
|
608
|
+
mock_response.json.return_value = {
|
|
609
|
+
"label": "a",
|
|
610
|
+
"confidence": 0.9,
|
|
611
|
+
"latency_ms": 30,
|
|
612
|
+
}
|
|
613
|
+
mock_post.return_value = mock_response
|
|
614
|
+
|
|
615
|
+
client = ClasserClient()
|
|
616
|
+
client.classify(text="test", labels=["a", "b"])
|
|
617
|
+
|
|
618
|
+
call_args = mock_post.call_args
|
|
619
|
+
assert call_args[0][0] == "https://api.classer.ai/v1/classify"
|
|
620
|
+
|
|
621
|
+
@patch("classer.client.httpx.post")
|
|
622
|
+
def test_tag_uses_correct_endpoint(self, mock_post):
|
|
623
|
+
mock_response = MagicMock()
|
|
624
|
+
mock_response.is_success = True
|
|
625
|
+
mock_response.json.return_value = {
|
|
626
|
+
"labels": [{"label": "a", "confidence": 0.9}],
|
|
627
|
+
"latency_ms": 30,
|
|
628
|
+
}
|
|
629
|
+
mock_post.return_value = mock_response
|
|
630
|
+
|
|
631
|
+
client = ClasserClient()
|
|
632
|
+
client.tag(text="test", labels=["a", "b"])
|
|
633
|
+
|
|
634
|
+
call_args = mock_post.call_args
|
|
635
|
+
assert call_args[0][0] == "https://api.classer.ai/v1/tag"
|