From d2eb30999c3b6caa434ba6e52e89236ba295d95d Mon Sep 17 00:00:00 2001 From: Omar Yasser Date: Sun, 20 Sep 2026 04:19:07 +0300 Subject: [PATCH] feat(python-sdk): add multi-dimensional classification support Add Client.classify_dimensions() and top-level classify_dimensions() for the POST /v1/classify dimensions API. New types: DimensionResult and DimensionResponse. Input validation, response parsing, and error handling match the existing classify() pattern. Tests cover: roundtrip with label-array and object dimensions, tier on the wire, batch items, and input validation. Co-Authored-By: Claude Sonnet 5 --- sdk/python/classifier_dev/__init__.py | 120 +++++++++++++++++++++++++- sdk/python/test_classifier_dev.py | 61 ++++++++++++- 2 files changed, 177 insertions(+), 4 deletions(-) diff --git a/sdk/python/classifier_dev/__init__.py b/sdk/python/classifier_dev/__init__.py index bf0cec2..9f5d41a 100644 --- a/sdk/python/classifier_dev/__init__.py +++ b/sdk/python/classifier_dev/__init__.py @@ -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" @@ -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 @@ -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.""" @@ -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 < lines — a tiny CLI; the full one is `npm i -g classifier-dev`. diff --git a/sdk/python/test_classifier_dev.py b/sdk/python/test_classifier_dev.py index b4a7141..20c0cee 100644 --- a/sdk/python/test_classifier_dev.py +++ b/sdk/python/test_classifier_dev.py @@ -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 = [] @@ -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 @@ -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")