Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 118 additions & 2 deletions sdk/python/classifier_dev/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence
from typing import Any, Dict, List, Optional, Sequence, Union

__all__ = ["classify", "Client", "Result", "Response", "ClassifierError"]
__all__ = ["classify", "classify_dimensions", "Client", "Result", "DimensionResult", "DimensionResponse", "Response", "ClassifierError"]
__version__ = "0.1.0"

DEFAULT_BASE_URL = "https://classifier.dev"
Expand Down Expand Up @@ -61,6 +61,18 @@ class Result:
model: Optional[str] = None


@dataclass
class DimensionResult:
"""One field in a multi-dimensional classification."""
label: Optional[str] = None
confidence: Optional[float] = None
scores: Optional[Dict[str, float]] = None
escalated: bool = False
unscored: Optional[str] = None
model: Optional[str] = None
ms: Optional[int] = None


@dataclass
class Response:
tier: str
Expand All @@ -70,6 +82,16 @@ class Response:
models_used: List[str] = field(default_factory=list)


@dataclass
class DimensionResponse:
"""Response from a multi-dimensional classification."""
tier: str
model: str
results: List[Dict[str, DimensionResult]]
usage: Dict[str, Any]
models_used: List[str] = field(default_factory=list)


class Client:
"""One classifier.dev deployment. ``api_key`` is optional: a partner key that lifts per-IP limits."""

Expand Down Expand Up @@ -174,11 +196,105 @@ def classify(
)


def classify_dimensions(
self,
items: Sequence[str],
dimensions: Dict[str, Union[List[str], Dict[str, Any]]],
*,
tier: Optional[str] = None,
) -> DimensionResponse:
"""Classify items across multiple independent dimensions in one call.

Each dimension is a name mapped to either a list of labels or
``{"labels": [...], "instructions": "..."}``. Up to 20 dimensions
and 1,000 item × dimension decisions.
"""
if isinstance(items, (str, bytes)):
raise ValueError("items must be a sequence, not a string")
try:
item_values = list(items)
except TypeError:
raise ValueError("items must be a sequence") from None
if not all(isinstance(text, str) for text in item_values):
raise ValueError("items must be a sequence of strings")
if not 1 <= len(item_values) <= 1000:
raise ValueError("items must hold 1 to 1,000 texts")
if not isinstance(dimensions, dict) or not dimensions:
raise ValueError("dimensions must be a non-empty dict")
if len(dimensions) > 20:
raise ValueError("at most 20 dimensions")
body: Dict[str, Any] = {"items": item_values, "dimensions": dimensions}
if tier:
body["tier"] = tier
headers = {"content-type": "application/json", "user-agent": f"classifier-dev-python/{__version__}"}
if self.api_key:
headers["authorization"] = f"Bearer {self.api_key}"
req = urllib.request.Request(f"{self.base_url}/v1/classify", data=json.dumps(body).encode(), headers=headers)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as res:
payload = json.load(res)
except urllib.error.HTTPError as e:
try:
err = json.loads(e.read() or b"{}")
except ValueError:
err = {}
if not isinstance(err, dict):
err = {}
retry = e.headers.get("Retry-After")
raise ClassifierError(err.get("error", f"HTTP {e.code}"), err.get("code", f"http_{e.code}"), e.code, int(retry) if retry and retry.isdigit() else None) from None
except (urllib.error.URLError, OSError) as e:
reason = getattr(e, "reason", e)
timed_out = isinstance(reason, (socket.timeout, TimeoutError)) or "timed out" in str(reason).lower()
raise ClassifierError(f"{'timed out' if timed_out else 'network error'}: {reason}", "timeout" if timed_out else "network", 0) from e
except ValueError as e:
raise ClassifierError(f"response was not JSON: {e}", "bad_response", 200) from None
if not isinstance(payload, dict):
raise ClassifierError("response was not a JSON object", "bad_response", 200)
results = payload.get("results")
if not isinstance(results, list) or len(results) != len(item_values):
raise ClassifierError(f"{len(results) if isinstance(results, list) else 0} results for {len(item_values)} items", "bad_response", 200)
parsed: List[Dict[str, DimensionResult]] = []
for i, raw_result in enumerate(results):
if not isinstance(raw_result, dict):
raise ClassifierError(f"result {i} was not a JSON object", "bad_response", 200)
dims = raw_result.get("dimensions")
if not isinstance(dims, dict):
raise ClassifierError(f"result {i} has no dimensions", "bad_response", 200)
row: Dict[str, DimensionResult] = {}
for dim_name, dim_val in dims.items():
if not isinstance(dim_val, dict):
raise ClassifierError(f"result {i} dimension {dim_name} is not an object", "bad_response", 200)
confidence = dim_val.get("confidence")
if not _probability(confidence) and confidence is not None:
confidence = None
scores = dim_val.get("scores")
if scores is not None and (not isinstance(scores, dict) or not all(isinstance(k, str) and _probability(v) for k, v in scores.items())):
scores = None
row[dim_name] = DimensionResult(
label=dim_val.get("label"), confidence=confidence,
scores=None if scores is None else dict(scores),
escalated=bool(dim_val.get("escalated")),
unscored=dim_val.get("unscored"), model=dim_val.get("model"),
ms=dim_val.get("ms"),
)
parsed.append(row)
return DimensionResponse(
tier=payload.get("tier", ""), model=payload.get("model", ""),
models_used=payload.get("modelsUsed", []), usage=payload.get("usage", {}),
results=parsed,
)


def classify(inputs: Sequence[str], labels: Sequence[str], **kwargs: Any) -> List[Result]:
"""One label per text, fast tier, default client. Keyword arguments as in :meth:`Client.classify`."""
return Client().classify(inputs, labels, **kwargs).results


def classify_dimensions(items: Sequence[str], dimensions: Dict[str, Union[List[str], Dict[str, Any]]], **kwargs: Any) -> List[Dict[str, DimensionResult]]:
"""Classify items across dimensions, default client. See :meth:`Client.classify_dimensions`."""
return Client().classify_dimensions(items, dimensions, **kwargs).results


def _cli() -> None:
"""classify-py <labels> < lines — a tiny CLI; the full one is `npm i -g classifier-dev`.

Expand Down
61 changes: 59 additions & 2 deletions sdk/python/test_classifier_dev.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for the Python client against a stand-in server. `python3 test_classifier_dev.py` or pytest."""
import json, os, subprocess, sys, threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from classifier_dev import Client, ClassifierError, classify
from classifier_dev import Client, ClassifierError, classify, classify_dimensions, DimensionResult

HERE = os.path.dirname(os.path.abspath(__file__))
REQUESTS = []
Expand All @@ -11,6 +11,18 @@ class H(BaseHTTPRequestHandler):
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["content-length"])))
REQUESTS.append((self.path, {k.lower(): v for k, v in self.headers.items()}, body))
if "dimensions" in body:
items = body.get("items") or body.get("inputs") or ([body["input"]] if "input" in body else [])
dims = body["dimensions"]
results = []
for _ in items:
row = {}
for name, dim in dims.items():
labels = dim if isinstance(dim, list) else dim["labels"]
row[name] = {"label": labels[0], "confidence": 0.85, "scores": {l: (0.85 if l == labels[0] else 0.15 / max(len(labels) - 1, 1)) for l in labels}, "model": "jev-test", "ms": 50}
results.append({"dimensions": row})
out = {"tier": body.get("tier", "fast"), "model": "jev-test", "modelsUsed": ["jev-test"], "results": results, "usage": {"items": len(items), "dimensions": len(dims), "classifications": len(items) * len(dims)}}
self.send_response(200); self.send_header("content-type", "application/json"); self.end_headers(); self.wfile.write(json.dumps(out).encode()); return
if "boom" in body["labels"]:
self.send_response(429); self.send_header("Retry-After", "7"); self.send_header("content-type", "application/json"); self.end_headers()
self.wfile.write(b'{"error":"Rate limit","code":"rate_limit_minute"}'); return
Expand Down Expand Up @@ -174,5 +186,50 @@ def test_structured_response_and_options():
srv.shutdown()


def test_dimensions():
srv, url = serve()
try:
c = Client(base_url=url)
dims = {"team": ["billing", "platform"], "kind": {"labels": ["bug", "request"], "instructions": "be strict"}}
res = c.classify_dimensions(["checkout broke"], dims)
assert len(res.results) == 1
assert "team" in res.results[0] and "kind" in res.results[0]
assert res.results[0]["team"].label == "billing"
assert res.results[0]["kind"].label == "bug"
assert isinstance(res.results[0]["team"], DimensionResult)
assert res.results[0]["team"].confidence == 0.85
assert isinstance(res.results[0]["team"].scores, dict)
assert res.usage["dimensions"] == 2

# Wire body check
_, _, body = REQUESTS[-1]
assert body["items"] == ["checkout broke"]
assert "dimensions" in body
assert "labels" not in body, "dimensions and labels must not be combined"

# Tier reaches the wire
c.classify_dimensions(["x"], {"a": ["x", "y"]}, tier="smart")
_, _, body = REQUESTS[-1]
assert body["tier"] == "smart"

# Batch: multiple items
res = c.classify_dimensions(["a", "b", "c"], {"team": ["billing", "platform"]})
assert len(res.results) == 3

# Validation
for bad_items, bad_dims in [
("string", {"a": ["x", "y"]}), # items is a string
([], {"a": ["x", "y"]}), # empty items
(["x"], {}), # empty dimensions
(["x"], "not a dict"), # dimensions not a dict
]:
try:
c.classify_dimensions(bad_items, bad_dims); assert False, f"should reject {bad_items!r}, {bad_dims!r}"
except (ValueError, TypeError):
pass
finally:
srv.shutdown()


if __name__ == "__main__":
test_roundtrip_and_error(); test_options_reach_the_wire(); test_every_failure_is_a_classifier_error(); test_cli(); test_structured_response_and_options(); print("ok")
test_roundtrip_and_error(); test_options_reach_the_wire(); test_every_failure_is_a_classifier_error(); test_cli(); test_structured_response_and_options(); test_dimensions(); print("ok")