Skip to content

Commit

Permalink
api/view: improve the collect of the Schema1 events
Browse files Browse the repository at this point in the history
Harmonize how we collect we prepare the Schema1 events and how we
do the payload validation and the exception handling.
  • Loading branch information
goneri committed Jun 25, 2024
1 parent 2c3df3b commit 93d00e5
Show file tree
Hide file tree
Showing 6 changed files with 495 additions and 309 deletions.
204 changes: 204 additions & 0 deletions ansible_ai_connect/ai/api/telemetry/schema1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# Copyright Red Hat
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
import platform

from attr import Factory, asdict, field
from attrs import define, validators
from django.apps import apps
from django.utils import timezone

from ansible_ai_connect.ai.api.exceptions import (
WcaModelIdNotFound,
WcaNoDefaultModelId,
WcaSecretManagerError,
)
from ansible_ai_connect.ai.api.serializers import (
InlineSuggestionFeedback,
SuggestionQualityFeedback,
)
from ansible_ai_connect.healthcheck.version_info import VersionInfo
from ansible_ai_connect.users.models import User

logger = logging.getLogger(__name__)
version_info = VersionInfo()


@define
class ResponsePayload:
exception: str = field(validator=validators.instance_of(str), converter=str, default="")


@define
class Schema1Event:
event_name: str = "noName"
imageTags: str = field(
validator=validators.instance_of(str), converter=str, default=version_info.image_tags
)
hostname: str = field(
validator=validators.instance_of(str), converter=str, default=platform.node()
)
groups: list[str] = Factory(list)

rh_user_has_seat: bool = False
rh_user_org_id: int | None = None
timestamp = timezone.now().isoformat()
modelName: str = field(validator=validators.instance_of(str), converter=str, default="")
exception: bool = False
response: ResponsePayload | None = ResponsePayload()
user: User | None = None

def set_user(self, user):
self.user = user
self.rh_user_has_seat = user.rh_user_has_seat
self.rh_user_org_id = user.org_id
self.groups = list(user.groups.values_list("name", flat=True))

def set_exception(self, exception):
if exception:
self.exception = True
self.response.exception = str(exception)

def set_validated_data(self, validated_data):
for field_name, value in validated_data.items():
if hasattr(self, field_name):
setattr(self, field_name, value)

# TODO: improve the way we define the model in the payload.
try:
model_mesh_client = apps.get_app_config("ai").model_mesh_client
self.modelName = model_mesh_client.get_model_id(
self.rh_user_org_id, str(validated_data.get("model", ""))
)
except (WcaNoDefaultModelId, WcaModelIdNotFound, WcaSecretManagerError):
logger.debug(
f"Failed to retrieve Model Name for Feedback.\n "
f"Org ID: {self.rh_user_org_id}, "
f"User has seat: {self.rh_user_has_seat}, "
f"has subscription: {self.user.rh_org_has_subscription}.\n"
)

def as_dict(self):
# NOTE: The allowed fields should be moved in the event class itslef
def my_filter(a, v):
return a.name not in ["event_name", "user"]

return asdict(self, filter=my_filter)


@define
class ExplainPlaybookEvent(Schema1Event):
event_name: str = "explainPlaybook"
explanationId: str = field(validator=validators.instance_of(str), converter=str, default="")
duration: int = field(validator=validators.instance_of(int), converter=int, default=0)


@define
class CodegenPlaybookEvent(Schema1Event):
event_name: str = "codegenPlaybook"
generationId: str = field(validator=validators.instance_of(str), converter=str, default="")
wizardId: str = field(validator=validators.instance_of(str), converter=str, default="")
playbook_length: int | None = None
duration: int = field(validator=validators.instance_of(int), converter=int, default=0)

def set_validated_data(self, validated_data):
super().set_validated_data(validated_data)
self.playbook_length = len(validated_data.get("playbook", ""))


@define
class ContentMatchEvent(Schema1Event):
event_name: str = "codematch"
duration: int = field(validator=validators.instance_of(int), converter=int, default=0)
request: dict | None = None
response: None = None
metadata: list | None = None # TODO

def set_validated_data(self, validated_data):
super().set_validated_data(validated_data)
self.request_data = validated_data


# Events associated with the Feedback view
@define
class BaseFeedbackEvent(Schema1Event):
def set_validated_data(self, validated_data):
super().set_validated_data(validated_data)
# event are wrapped in the FeedbackRequestSerializer class
suggestion_quality_data: SuggestionQualityFeedback = validated_data[self.event_name]
super().set_validated_data(suggestion_quality_data)


@define
class InlineSuggestionFeedbackEvent(BaseFeedbackEvent):
event_name: str = "inlineSuggestionFeedback"
latency: float = field(validator=validators.instance_of(float), converter=float, default=0.0)
userActionTime: int = field(validator=validators.instance_of(int), converter=int, default=0)
action: int = field(validator=validators.instance_of(int), converter=int, default=0)
suggestionId: str = field(validator=validators.instance_of(str), converter=str, default="")
activityId: str = field(validator=validators.instance_of(str), converter=str, default="")

# Remove the method one year after https://github.com/ansible/vscode-ansible/pull/1408 is merged
# and released
def set_validated_data(self, validated_data):
super().set_validated_data(validated_data)
inlineSuggestion: InlineSuggestionFeedback = validated_data.get(
self.event_name
) or validated_data.get("inlineSuggestion")
super().set_validated_data(inlineSuggestion)


@define
class SuggestionQualityFeedbackEvent(BaseFeedbackEvent):
event_name: str = "suggestionQualityFeedback"
prompt: str = field(validator=validators.instance_of(str), converter=str, default="")
providedSuggestion: str = field(
validator=validators.instance_of(str), converter=str, default=""
)
expectedSuggestion: str = field(
validator=validators.instance_of(str), converter=str, default=""
)
additionalComment: str = field(validator=validators.instance_of(str), converter=str, default="")


@define
class SentimentFeedbackEvent(BaseFeedbackEvent):
event_name: str = "sentimentFeedback"
value: int = field(validator=validators.instance_of(int), converter=int, default=0)
feedback: str = field(validator=validators.instance_of(str), converter=str, default="")


@define
class IssueFeedbackEvent(BaseFeedbackEvent):
event_name: str = "issueFeedback"
type: str = field(validator=validators.instance_of(str), converter=str, default="")
title: str = field(validator=validators.instance_of(str), converter=str, default="")
description: str = field(validator=validators.instance_of(str), converter=str, default="")


@define
class PlaybookExplanationFeedbackEvent(BaseFeedbackEvent):
event_name: str = "playbookExplanationFeedback"
action: int = field(validator=validators.instance_of(int), converter=int, default=0)
explanation_id: str = field(validator=validators.instance_of(str), converter=str, default="")


@define
class PlaybookGenerationActionEvent(BaseFeedbackEvent):
event_name: str = "playbookGenerationAction"
action: int = field(validator=validators.instance_of(int), converter=int, default=0)
from_page: int = field(validator=validators.instance_of(int), converter=int, default=0)
to_page: int = field(validator=validators.instance_of(int), converter=int, default=0)
wizard_id: str = field(validator=validators.instance_of(str), converter=str, default="")
127 changes: 127 additions & 0 deletions ansible_ai_connect/ai/api/telemetry/test_schema1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Copyright Red Hat
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from unittest import TestCase, mock

from .schema1 import (
InlineSuggestionFeedbackEvent,
IssueFeedbackEvent,
PlaybookExplanationFeedbackEvent,
PlaybookGenerationActionEvent,
Schema1Event,
SentimentFeedbackEvent,
SuggestionQualityFeedbackEvent,
)


class TestSchema1Event(TestCase):
def test_set_user(self):
m_user = mock.Mock()
m_user.rh_user_has_seat = True
m_user.org_id = 123
m_user.groups.values_list.return_value = ["mecano"]
event1 = Schema1Event()
event1.set_user(m_user)
self.assertEqual(event1.rh_user_has_seat, True)
self.assertEqual(event1.rh_user_org_id, 123)
self.assertEqual(event1.groups, ["mecano"])

def test_as_dict(self):
event1 = Schema1Event()
as_dict = event1.as_dict()

self.assertEqual(as_dict.get("event_name"), None)
self.assertFalse(as_dict.get("exception"), False)

def test_set_exception(self):
event1 = Schema1Event()
try:
1 / 0
except Exception as e:
event1.set_exception(e)
self.assertTrue(event1.exception)
self.assertEqual(event1.response.exception, "division by zero")


class TestInlineSuggestionFeedbackEvent(TestCase):
def test_validated_data(self):
validated_data = {
"inlineSuggestion": {
"latency": 1.1,
"userActionTime": 1,
"action": "123",
"suggestionId": "1e0e1404-5b8a-4d06-829a-dca0d2fff0b5",
}
}
event1 = InlineSuggestionFeedbackEvent(validated_data)
self.assertEqual(event1.action, 0)
event1.set_validated_data(validated_data)
self.assertEqual(event1.action, 123)


class TestSuggestionQualityFeedbackEvent(TestCase):
def test_validated_data(self):
validated_data = {
"suggestionQualityFeedback": {"prompt": "Yo!", "providedSuggestion": "bateau"}
}
event1 = SuggestionQualityFeedbackEvent()
event1.set_validated_data(validated_data)
self.assertEqual(event1.providedSuggestion, "bateau")


class TestSentimentFeedbackEvent(TestCase):
def test_validated_data(self):
validated_data = {"sentimentFeedback": {"value": "1", "feedback": "C'est beau"}}
event1 = SentimentFeedbackEvent()
event1.set_validated_data(validated_data)
self.assertEqual(event1.value, 1)


class TestIssueFeedbackEvent(TestCase):
def test_validated_data(self):
validated_data = {
"issueFeedback": {"type": "1", "title": "C'est beau", "description": "Et oui!"}
}
event1 = IssueFeedbackEvent()
event1.set_validated_data(validated_data)
self.assertEqual(event1.title, "C'est beau")


class TestPlaybookExplanationFeedbackEvent(TestCase):
def test_validated_data(self):
validated_data = {
"playbookExplanationFeedback": {
"action": "1",
"explanation": "1ddda23c-5f8c-4015-b915-4951b8039ffa",
}
}
event1 = PlaybookExplanationFeedbackEvent()
event1.set_validated_data(validated_data)
self.assertEqual(event1.action, 1)


class TestPlaybookGenerationActionEvent(TestCase):
def test_validated_data(self):
validated_data = {
"playbookGenerationAction": {
"action": "2",
"from_page": 1,
"to_page": "2",
"wizard_id": "1ddda23c-5f8c-4015-b915-4951b8039ffa",
}
}
event1 = PlaybookGenerationActionEvent()
event1.set_validated_data(validated_data)
self.assertEqual(event1.action, 2)
self.assertEqual(event1.to_page, 2)
2 changes: 2 additions & 0 deletions ansible_ai_connect/ai/api/utils/analytics_telemetry_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

# Schema2

from enum import Enum

from attr import Factory, field, frozen
Expand Down
8 changes: 8 additions & 0 deletions ansible_ai_connect/ai/api/utils/segment.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ def base_send_segment_event(
send_segment_event(event, "segmentError", user)


def send_schema1_event(event_obj) -> None:
print(event_obj)
if not settings.SEGMENT_WRITE_KEY:
logger.info("segment write key not set, skipping event")
return
base_send_segment_event(event_obj.as_dict(), event_obj.event_name, event_obj.user, analytics)


def redact_seated_users_data(event: Dict[str, Any], allow_list: Dict[str, Any]) -> Dict[str, Any]:
"""
Copy a dictionary to another dictionary using a nested list of allowed keys.
Expand Down
Loading

0 comments on commit 93d00e5

Please sign in to comment.