diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 5b8765b8..bc9649b2 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -27,12 +27,23 @@ from groundlight_openapi_client.model.rule_request import RuleRequest from groundlight_openapi_client.model.status_enum import StatusEnum from groundlight_openapi_client.model.verb_enum import VerbEnum -from model import ROI, BBoxGeometry, Detector, DetectorGroup, ModeEnum, PaginatedRuleList, Rule +from model import ( + ROI, + Action, + ActionList, + BBoxGeometry, + Condition, + Detector, + DetectorGroup, + ModeEnum, + PaginatedRuleList, + Rule, +) from groundlight.images import parse_supported_image_types from groundlight.optional_imports import Image, np -from .client import DEFAULT_REQUEST_TIMEOUT, Groundlight +from .client import DEFAULT_REQUEST_TIMEOUT, Groundlight, logger class ExperimentalApi(Groundlight): @@ -93,6 +104,142 @@ def __init__( ITEMS_PER_PAGE = 100 + def make_condition(self, verb: str, parameters: dict) -> Condition: + """ + Creates a Condition object for use in creating alerts + + This function serves as a convenience method; Condition objects can also be created directly. + + **Example usage**:: + + gl = ExperimentalApi() + + # Create a condition for a rule + condition = gl.make_condition("CHANGED_TO", {"label": "YES"}) + + :param verb: The condition verb to use. One of "ANSWERED_CONSECUTIVELY", "ANSWERED_WITHIN_TIME", + "CHANGED_TO", "NO_CHANGE", "NO_QUERIES" + :param condition_parameters: Additional parameters for the condition, dependant on the verb: + - For ANSWERED_CONSECUTIVELY: {"num_consecutive_labels": N, "label": "YES/NO"} + - For CHANGED_TO: {"label": "YES/NO"} + - For ANSWERED_WITHIN_TIME: {"time_value": N, "time_unit": "MINUTES/HOURS/DAYS"} + + :return: The created Condition object + """ + return Condition(verb=verb, parameters=parameters) + + def make_action( + self, + channel: str, + recipient: str, + include_image: bool, + ) -> Action: + """ + Creates an Action object for use in creating alerts + + This function serves as a convenience method; Action objects can also be created directly. + + **Example usage**:: + + gl = ExperimentalApi() + + # Create an action for an alert + action = gl.make_action("EMAIL", "example@example.com", include_image=True) + + :param channel: The notification channel to use. One of "EMAIL" or "TEXT" + :param recipient: The email address or phone number to send notifications to + :param include_image: Whether to include the triggering image in action message + """ + return Action( + channel=channel, + recipient=recipient, + include_image=include_image, + ) + + def create_alert( # pylint: disable=too-many-locals # noqa: PLR0913 + self, + detector: Union[str, Detector], + name, + condition: Condition, + actions: Union[Action, List[Action], ActionList], + *, + enabled: bool = True, + snooze_time_enabled: bool = False, + snooze_time_value: int = 3600, + snooze_time_unit: str = "SECONDS", + human_review_required: bool = False, + ) -> Rule: + """ + Creates an alert for a detector that will trigger actions based on specified conditions. + + An alert allows you to configure automated actions when certain conditions are met, + such as when a detector's prediction changes or maintains a particular state. + + .. note:: + Currently, only binary mode detectors (YES/NO answers) are supported for alerts. + + **Example usage**:: + + gl = ExperimentalApi() + + # Create a rule to send email alerts when door is detected as open + condition = gl.make_condition( + verb="CHANGED_TO", + parameters={"label": "YES"} + ) + action1 = gl.make_action( + "EMAIL", + "alerts@company.com", + include_image=True + ) + action2 = gl.make_action( + "TEXT", + "+1234567890", + include_image=False + ) + alert = gl.create_alert( + detector="det_idhere", + name="Door Open Alert", + condition=condition, + actions=[action1, action2] + ) + + :param detector: The detector ID or Detector object to add the alert to + :param name: A unique name to identify this alert + :param enabled: Whether the alert should be active when created (default True) + :param snooze_time_enabled: Enable notification snoozing to prevent alert spam (default False) + :param snooze_time_value: Duration of snooze period (default 3600) + :param snooze_time_unit: Unit for snooze duration - "SECONDS", "MINUTES", "HOURS", or "DAYS" (default "SECONDS") + :param human_review_required: Require human verification before sending alerts (default False) + + :return: The created Alert object + """ + if isinstance(actions, Action): + actions = [actions] + elif isinstance(actions, ActionList): + actions = actions.root + if isinstance(detector, Detector): + detector = detector.id + # translate pydantic type to the openapi type + actions = [ + ActionRequest( + channel=ChannelEnum(action.channel), recipient=action.recipient, include_image=action.include_image + ) + for action in actions + ] + rule_input = RuleRequest( + detector_id=detector, + name=name, + enabled=enabled, + action=actions, + condition=ConditionRequest(verb=VerbEnum(condition.verb), parameters=condition.parameters), + snooze_time_enabled=snooze_time_enabled, + snooze_time_value=snooze_time_value, + snooze_time_unit=snooze_time_unit, + human_review_required=human_review_required, + ) + return Rule.model_validate(self.actions_api.create_rule(detector, rule_input).to_dict()) + def create_rule( # pylint: disable=too-many-locals # noqa: PLR0913 self, detector: Union[str, Detector], @@ -168,6 +315,9 @@ def create_rule( # pylint: disable=too-many-locals # noqa: PLR0913 :return: The created Rule object """ + + logger.warning("create_rule is no longer supported. Please use create_alert instead.") + if condition_parameters is None: condition_parameters = {} if isinstance(alert_on, str): diff --git a/test/integration/test_groundlight.py b/test/integration/test_groundlight.py index e344f784..bc45dfd7 100644 --- a/test/integration/test_groundlight.py +++ b/test/integration/test_groundlight.py @@ -37,7 +37,8 @@ def is_valid_display_result(result: Any) -> bool: and not isinstance(result, MultiClassificationResult) ): return False - if not is_valid_display_label(result.label): + + if isinstance(result, BinaryClassificationResult) and not is_valid_display_label(result.label): return False return True diff --git a/test/unit/test_actions.py b/test/unit/test_actions.py index d620255b..a437acc6 100644 --- a/test/unit/test_actions.py +++ b/test/unit/test_actions.py @@ -52,3 +52,19 @@ def test_delete_action(gl_experimental: ExperimentalApi): gl_experimental.delete_rule(rule.id) with pytest.raises(NotFoundException) as _: gl_experimental.get_rule(rule.id) + + +def test_create_alert_multiple_actions(gl_experimental: ExperimentalApi): + name = f"Test {datetime.utcnow()}" + det = gl_experimental.get_or_create_detector(name, "test_query") + condition = gl_experimental.make_condition("CHANGED_TO", {"label": "YES"}) + action1 = gl_experimental.make_action("EMAIL", "test@groundlight.ai", False) + action2 = gl_experimental.make_action("EMAIL", "test@groundlight.ai", False) + actions = [action1, action2] + alert = gl_experimental.create_alert( + det, + f"test_alert_{name}", + condition, + actions, + ) + assert len(alert.action.root) == len(actions)