Skip to content

Commit 34fa697

Browse files
feat(autofix automation): Use user preference as upper bound for stopping point [feature flagged] (#103237)
+ With the V0 launch, we are asking the model to decide a stopping stage for autofix but we are still keeping the user setting to decide the stopping point. + This adds logic to upper bound the stopping stage with the user preference. + Notion doc [reference](https://www.notion.so/sentry/Triage-Signals-V0-Technical-Implementation-Details-2a18b10e4b5d8086a7ceddaf4194849a?source=copy_link#2a98b10e4b5d808c8799cb0000aaa853) + Further optimization - we can cache user preferences for a an hour or so reduce API calls.
1 parent b1daf84 commit 34fa697

File tree

2 files changed

+286
-2
lines changed

2 files changed

+286
-2
lines changed

src/sentry/seer/autofix/issue_summary.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@
5151
SeerAutomationSource.POST_PROCESS: "issue_summary_on_post_process_fixability",
5252
}
5353

54+
STOPPING_POINT_HIERARCHY = {
55+
AutofixStoppingPoint.ROOT_CAUSE: 1,
56+
AutofixStoppingPoint.SOLUTION: 2,
57+
AutofixStoppingPoint.CODE_CHANGES: 3,
58+
AutofixStoppingPoint.OPEN_PR: 4,
59+
}
60+
5461

5562
def _get_stopping_point_from_fixability(fixability_score: float) -> AutofixStoppingPoint | None:
5663
"""
@@ -64,6 +71,58 @@ def _get_stopping_point_from_fixability(fixability_score: float) -> AutofixStopp
6471
return AutofixStoppingPoint.CODE_CHANGES
6572

6673

74+
def _fetch_user_preference(project_id: int) -> str | None:
75+
"""
76+
Fetch the user's automated_run_stopping_point preference from Seer.
77+
Returns None if preference is not set or if the API call fails.
78+
"""
79+
try:
80+
path = "/v1/project-preference"
81+
body = orjson.dumps({"project_id": project_id})
82+
83+
response = requests.post(
84+
f"{settings.SEER_AUTOFIX_URL}{path}",
85+
data=body,
86+
headers={
87+
"content-type": "application/json;charset=utf-8",
88+
**sign_with_seer_secret(body),
89+
},
90+
timeout=5,
91+
)
92+
response.raise_for_status()
93+
94+
result = response.json()
95+
preference = result.get("preference")
96+
if preference:
97+
return preference.get("automated_run_stopping_point")
98+
return None
99+
except Exception as e:
100+
sentry_sdk.set_context("project", {"project_id": project_id})
101+
sentry_sdk.capture_exception(e)
102+
return None
103+
104+
105+
def _apply_user_preference_upper_bound(
106+
fixability_suggestion: AutofixStoppingPoint | None,
107+
user_preference: str | None,
108+
) -> AutofixStoppingPoint | None:
109+
"""
110+
Apply user preference as an upper bound on the fixability-based stopping point.
111+
Returns the more conservative (earlier) stopping point between the two.
112+
"""
113+
if fixability_suggestion is None or user_preference is None:
114+
return fixability_suggestion
115+
116+
user_stopping_point = AutofixStoppingPoint(user_preference)
117+
118+
return (
119+
fixability_suggestion
120+
if STOPPING_POINT_HIERARCHY[fixability_suggestion]
121+
<= STOPPING_POINT_HIERARCHY[user_stopping_point]
122+
else user_stopping_point
123+
)
124+
125+
67126
@instrumented_task(
68127
name="sentry.tasks.autofix.trigger_autofix_from_issue_summary",
69128
namespace=seer_tasks,
@@ -277,8 +336,19 @@ def _run_automation(
277336

278337
stopping_point = None
279338
if features.has("projects:triage-signals-v0", group.project):
280-
stopping_point = _get_stopping_point_from_fixability(issue_summary.scores.fixability_score)
281-
logger.info("Fixability-based stopping point: %s", stopping_point)
339+
fixability_stopping_point = _get_stopping_point_from_fixability(
340+
issue_summary.scores.fixability_score
341+
)
342+
logger.info("Fixability-based stopping point: %s", fixability_stopping_point)
343+
344+
# Fetch user preference and apply as upper bound
345+
user_preference = _fetch_user_preference(group.project.id)
346+
logger.info("User preference stopping point: %s", user_preference)
347+
348+
stopping_point = _apply_user_preference_upper_bound(
349+
fixability_stopping_point, user_preference
350+
)
351+
logger.info("Final stopping point after upper bound: %s", stopping_point)
282352

283353
_trigger_autofix_task.delay(
284354
group_id=group.id,

tests/sentry/seer/autofix/test_issue_summary.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
from sentry.locks import locks
1414
from sentry.seer.autofix.constants import SeerAutomationSource
1515
from sentry.seer.autofix.issue_summary import (
16+
_apply_user_preference_upper_bound,
1617
_call_seer,
18+
_fetch_user_preference,
1719
_get_event,
1820
_get_stopping_point_from_fixability,
1921
_run_automation,
@@ -789,3 +791,215 @@ def test_without_feature_flag(self, mock_gen, mock_budget, mock_state, mock_rate
789791

790792
mock_trigger.assert_called_once()
791793
assert mock_trigger.call_args[1]["stopping_point"] is None
794+
795+
796+
class TestFetchUserPreference:
797+
@patch("sentry.seer.autofix.issue_summary.sign_with_seer_secret", return_value={})
798+
@patch("sentry.seer.autofix.issue_summary.requests.post")
799+
def test_fetch_user_preference_success(self, mock_post, mock_sign):
800+
mock_response = Mock()
801+
mock_response.json.return_value = {
802+
"preference": {"automated_run_stopping_point": "solution"}
803+
}
804+
mock_response.raise_for_status = Mock()
805+
mock_post.return_value = mock_response
806+
807+
result = _fetch_user_preference(project_id=123)
808+
809+
assert result == "solution"
810+
mock_post.assert_called_once()
811+
mock_response.raise_for_status.assert_called_once()
812+
813+
@patch("sentry.seer.autofix.issue_summary.sign_with_seer_secret", return_value={})
814+
@patch("sentry.seer.autofix.issue_summary.requests.post")
815+
def test_fetch_user_preference_no_preference(self, mock_post, mock_sign):
816+
mock_response = Mock()
817+
mock_response.json.return_value = {"preference": None}
818+
mock_response.raise_for_status = Mock()
819+
mock_post.return_value = mock_response
820+
821+
result = _fetch_user_preference(project_id=123)
822+
823+
assert result is None
824+
825+
@patch("sentry.seer.autofix.issue_summary.sign_with_seer_secret", return_value={})
826+
@patch("sentry.seer.autofix.issue_summary.requests.post")
827+
def test_fetch_user_preference_empty_preference(self, mock_post, mock_sign):
828+
mock_response = Mock()
829+
mock_response.json.return_value = {"preference": {"automated_run_stopping_point": None}}
830+
mock_response.raise_for_status = Mock()
831+
mock_post.return_value = mock_response
832+
833+
result = _fetch_user_preference(project_id=123)
834+
835+
assert result is None
836+
837+
@patch("sentry.seer.autofix.issue_summary.sign_with_seer_secret", return_value={})
838+
@patch("sentry.seer.autofix.issue_summary.requests.post")
839+
def test_fetch_user_preference_api_error(self, mock_post, mock_sign):
840+
mock_post.side_effect = Exception("API error")
841+
842+
result = _fetch_user_preference(project_id=123)
843+
844+
assert result is None
845+
846+
847+
class TestApplyUserPreferenceUpperBound:
848+
@pytest.mark.parametrize(
849+
"fixability,user_pref,expected",
850+
[
851+
# Fixability is None - always return None
852+
(None, "open_pr", None),
853+
(None, "solution", None),
854+
(None, None, None),
855+
# User preference is None - return fixability suggestion
856+
(AutofixStoppingPoint.OPEN_PR, None, AutofixStoppingPoint.OPEN_PR),
857+
(AutofixStoppingPoint.CODE_CHANGES, None, AutofixStoppingPoint.CODE_CHANGES),
858+
(AutofixStoppingPoint.SOLUTION, None, AutofixStoppingPoint.SOLUTION),
859+
(AutofixStoppingPoint.ROOT_CAUSE, None, AutofixStoppingPoint.ROOT_CAUSE),
860+
# User preference limits automation (user is more conservative)
861+
(
862+
AutofixStoppingPoint.OPEN_PR,
863+
"code_changes",
864+
AutofixStoppingPoint.CODE_CHANGES,
865+
),
866+
(AutofixStoppingPoint.OPEN_PR, "solution", AutofixStoppingPoint.SOLUTION),
867+
(AutofixStoppingPoint.OPEN_PR, "root_cause", AutofixStoppingPoint.ROOT_CAUSE),
868+
(AutofixStoppingPoint.CODE_CHANGES, "solution", AutofixStoppingPoint.SOLUTION),
869+
(
870+
AutofixStoppingPoint.CODE_CHANGES,
871+
"root_cause",
872+
AutofixStoppingPoint.ROOT_CAUSE,
873+
),
874+
(AutofixStoppingPoint.SOLUTION, "root_cause", AutofixStoppingPoint.ROOT_CAUSE),
875+
# Fixability is more conservative (fixability limits automation)
876+
(AutofixStoppingPoint.SOLUTION, "open_pr", AutofixStoppingPoint.SOLUTION),
877+
(
878+
AutofixStoppingPoint.SOLUTION,
879+
"code_changes",
880+
AutofixStoppingPoint.SOLUTION,
881+
),
882+
(AutofixStoppingPoint.ROOT_CAUSE, "open_pr", AutofixStoppingPoint.ROOT_CAUSE),
883+
(
884+
AutofixStoppingPoint.ROOT_CAUSE,
885+
"code_changes",
886+
AutofixStoppingPoint.ROOT_CAUSE,
887+
),
888+
(AutofixStoppingPoint.ROOT_CAUSE, "solution", AutofixStoppingPoint.ROOT_CAUSE),
889+
# Same level - return fixability
890+
(AutofixStoppingPoint.OPEN_PR, "open_pr", AutofixStoppingPoint.OPEN_PR),
891+
(
892+
AutofixStoppingPoint.CODE_CHANGES,
893+
"code_changes",
894+
AutofixStoppingPoint.CODE_CHANGES,
895+
),
896+
(AutofixStoppingPoint.SOLUTION, "solution", AutofixStoppingPoint.SOLUTION),
897+
(
898+
AutofixStoppingPoint.ROOT_CAUSE,
899+
"root_cause",
900+
AutofixStoppingPoint.ROOT_CAUSE,
901+
),
902+
],
903+
)
904+
def test_upper_bound_combinations(self, fixability, user_pref, expected):
905+
result = _apply_user_preference_upper_bound(fixability, user_pref)
906+
assert result == expected
907+
908+
909+
@with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True})
910+
class TestRunAutomationWithUpperBound(APITestCase, SnubaTestCase):
911+
def setUp(self) -> None:
912+
super().setUp()
913+
self.group = self.create_group()
914+
event_data = load_data("python")
915+
self.event = self.store_event(data=event_data, project_id=self.project.id)
916+
917+
@patch("sentry.seer.autofix.issue_summary._trigger_autofix_task.delay")
918+
@patch("sentry.seer.autofix.issue_summary._fetch_user_preference")
919+
@patch(
920+
"sentry.seer.autofix.issue_summary.is_seer_autotriggered_autofix_rate_limited",
921+
return_value=False,
922+
)
923+
@patch("sentry.seer.autofix.issue_summary.get_autofix_state", return_value=None)
924+
@patch("sentry.quotas.backend.has_available_reserved_budget", return_value=True)
925+
@patch("sentry.seer.autofix.issue_summary._generate_fixability_score")
926+
def test_user_preference_limits_high_fixability(
927+
self, mock_gen, mock_budget, mock_state, mock_rate, mock_fetch, mock_trigger
928+
):
929+
"""High fixability (CODE_CHANGES) limited by user preference (SOLUTION)"""
930+
self.project.update_option("sentry:autofix_automation_tuning", "always")
931+
mock_gen.return_value = SummarizeIssueResponse(
932+
group_id=str(self.group.id),
933+
headline="h",
934+
whats_wrong="w",
935+
trace="t",
936+
possible_cause="c",
937+
scores=SummarizeIssueScores(fixability_score=0.80), # High = CODE_CHANGES
938+
)
939+
mock_fetch.return_value = "solution"
940+
941+
_run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT)
942+
943+
mock_trigger.assert_called_once()
944+
# Should be limited to SOLUTION by user preference
945+
assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.SOLUTION
946+
947+
@patch("sentry.seer.autofix.issue_summary._trigger_autofix_task.delay")
948+
@patch("sentry.seer.autofix.issue_summary._fetch_user_preference")
949+
@patch(
950+
"sentry.seer.autofix.issue_summary.is_seer_autotriggered_autofix_rate_limited",
951+
return_value=False,
952+
)
953+
@patch("sentry.seer.autofix.issue_summary.get_autofix_state", return_value=None)
954+
@patch("sentry.quotas.backend.has_available_reserved_budget", return_value=True)
955+
@patch("sentry.seer.autofix.issue_summary._generate_fixability_score")
956+
def test_fixability_limits_permissive_user_preference(
957+
self, mock_gen, mock_budget, mock_state, mock_rate, mock_fetch, mock_trigger
958+
):
959+
"""Medium fixability (SOLUTION) used despite user allowing OPEN_PR"""
960+
self.project.update_option("sentry:autofix_automation_tuning", "always")
961+
mock_gen.return_value = SummarizeIssueResponse(
962+
group_id=str(self.group.id),
963+
headline="h",
964+
whats_wrong="w",
965+
trace="t",
966+
possible_cause="c",
967+
scores=SummarizeIssueScores(fixability_score=0.50), # Medium = SOLUTION
968+
)
969+
mock_fetch.return_value = "open_pr"
970+
971+
_run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT)
972+
973+
mock_trigger.assert_called_once()
974+
# Should use SOLUTION from fixability, not OPEN_PR from user
975+
assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.SOLUTION
976+
977+
@patch("sentry.seer.autofix.issue_summary._trigger_autofix_task.delay")
978+
@patch("sentry.seer.autofix.issue_summary._fetch_user_preference")
979+
@patch(
980+
"sentry.seer.autofix.issue_summary.is_seer_autotriggered_autofix_rate_limited",
981+
return_value=False,
982+
)
983+
@patch("sentry.seer.autofix.issue_summary.get_autofix_state", return_value=None)
984+
@patch("sentry.quotas.backend.has_available_reserved_budget", return_value=True)
985+
@patch("sentry.seer.autofix.issue_summary._generate_fixability_score")
986+
def test_no_user_preference_uses_fixability_only(
987+
self, mock_gen, mock_budget, mock_state, mock_rate, mock_fetch, mock_trigger
988+
):
989+
"""When user has no preference, use fixability score alone"""
990+
self.project.update_option("sentry:autofix_automation_tuning", "always")
991+
mock_gen.return_value = SummarizeIssueResponse(
992+
group_id=str(self.group.id),
993+
headline="h",
994+
whats_wrong="w",
995+
trace="t",
996+
possible_cause="c",
997+
scores=SummarizeIssueScores(fixability_score=0.80), # High = CODE_CHANGES
998+
)
999+
mock_fetch.return_value = None
1000+
1001+
_run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT)
1002+
1003+
mock_trigger.assert_called_once()
1004+
# Should use CODE_CHANGES from fixability
1005+
assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.CODE_CHANGES

0 commit comments

Comments
 (0)