Skip to content
154 changes: 152 additions & 2 deletions src/groundlight/experimental_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion test/integration/test_groundlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

separate change that needed to get in to support different result types


if isinstance(result, BinaryClassificationResult) and not is_valid_display_label(result.label):
return False
return True

Expand Down
16 changes: 16 additions & 0 deletions test/unit/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading