mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-17 04:37:00 +00:00
* feat(classifier): add optional Jev component and CLI demo * fix(classifier): expose Jev demo through inherited default service
121 lines
5.3 KiB
Python
121 lines
5.3 KiB
Python
"""Optional TypeSafe Jev classifier, initialized only when called."""
|
|
|
|
import asyncio
|
|
from collections.abc import Mapping
|
|
from importlib import import_module
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter
|
|
|
|
from .base_as_classifier import BaseAsClassifier
|
|
from ..component_registry import R
|
|
|
|
if TYPE_CHECKING:
|
|
from agentscope.classifier import ClassifierResponse, JevClassifierModel
|
|
|
|
|
|
class _JevOptions(BaseModel):
|
|
"""Validate transport options without importing optional provider modules."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
model: str = Field(default="jev-latest", min_length=1)
|
|
timeout: float = Field(default=30.0, gt=0, allow_inf_nan=False)
|
|
max_retries: int = Field(default=2, ge=0)
|
|
retry_delay: float = Field(default=0.5, ge=0, allow_inf_nan=False)
|
|
|
|
|
|
@R.register("jev")
|
|
class JevAsClassifier(BaseAsClassifier):
|
|
"""Wrap AgentScope's Jev API without requiring its extra during startup.
|
|
|
|
``questions`` accepts AgentScope question objects or JSON dictionaries with
|
|
a ``type`` discriminator (``binary``, ``choice``, or ``score``). Only this
|
|
component's configuration supplies credentials and transport options.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
credential: dict | None = None,
|
|
model: str = "jev-latest",
|
|
timeout: float = 30.0,
|
|
max_retries: int = 2,
|
|
retry_delay: float = 0.5,
|
|
**kwargs,
|
|
) -> None:
|
|
super().__init__(**kwargs)
|
|
self.credential = dict(credential or {})
|
|
self.options = _JevOptions(model=model, timeout=timeout, max_retries=max_retries, retry_delay=retry_delay)
|
|
self._model: "JevClassifierModel | None" = None
|
|
self._active_calls = 0
|
|
self._idle = asyncio.Event()
|
|
self._idle.set()
|
|
|
|
@staticmethod
|
|
def _load_classifier():
|
|
"""Delay the optional AgentScope classifier import until classification."""
|
|
try:
|
|
module = import_module("agentscope.classifier")
|
|
# Check exports as well as module presence for older installations.
|
|
return module.ClassifierQuestion, module.JevClassifierModel
|
|
except (ImportError, AttributeError) as exc:
|
|
raise ImportError(
|
|
"Jev classification requires compatible AgentScope classifier support. "
|
|
"Install it with `pip install 'reme-ai[classifier-jev]'`.",
|
|
) from exc
|
|
|
|
async def __call__(self, state: str | dict, questions: Mapping[str, Any]) -> "ClassifierResponse":
|
|
"""Validate the request before constructing or calling the provider."""
|
|
if not isinstance(state, (str, dict)) or (isinstance(state, str) and not state.strip()):
|
|
raise ValueError("Classifier state must be non-empty text or a JSON object.")
|
|
if isinstance(state, dict):
|
|
state = TypeAdapter(dict[str, JsonValue]).validate_python(state, strict=True)
|
|
json.dumps(state, allow_nan=False)
|
|
if not isinstance(questions, Mapping) or not questions:
|
|
raise ValueError("Classifier questions must be a non-empty mapping.")
|
|
if any(not isinstance(name, str) or not name.strip() for name in questions):
|
|
raise ValueError("Classifier question names must be non-empty strings.")
|
|
|
|
question_type, model_cls = self._load_classifier()
|
|
adapter = TypeAdapter(question_type)
|
|
parsed = {name: adapter.validate_python(question) for name, question in questions.items()}
|
|
await self.start()
|
|
async with self._lock:
|
|
if not self.is_started:
|
|
raise RuntimeError("Classifier component closed before the request could start.")
|
|
if self._model is None:
|
|
credential = dict(self.credential)
|
|
api_key = credential.get("api_key")
|
|
if not isinstance(api_key, str) or not api_key.strip():
|
|
raise ValueError("Jev classification requires CLASSIFIER_API_KEY or credential.api_key.")
|
|
if not credential.get("base_url"):
|
|
credential["base_url"] = None
|
|
try:
|
|
credential_cls = import_module("agentscope.credential").TypeSafeCredential
|
|
self._model = model_cls(credential=credential_cls(**credential), **self.options.model_dump())
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"Jev classification requires a compatible typesafe-sdk. "
|
|
"Install it with `pip install 'reme-ai[classifier-jev]'`.",
|
|
) from exc
|
|
model = self._model
|
|
self._active_calls += 1
|
|
self._idle.clear()
|
|
try:
|
|
return await model(state=state, questions=parsed)
|
|
finally:
|
|
# No await here: finishing requests must unblock close(), which owns
|
|
# the lifecycle lock while draining active requests.
|
|
self._active_calls -= 1
|
|
if self._active_calls == 0:
|
|
self._idle.set()
|
|
|
|
async def _close(self) -> None:
|
|
"""Release the owned TypeSafe client and allow a later fresh start."""
|
|
try:
|
|
await self._idle.wait()
|
|
finally:
|
|
model, self._model = self._model, None
|
|
if model is not None:
|
|
await model.client.aclose()
|