-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
fix(seer): Make widget conditions readable for the Seer Explorer agent #112502
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Mihir-Mavalankar
merged 3 commits into
master
from
mihir-mavalankar/fix/readable-llm-conditions
Apr 8, 2026
Merged
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
b0d2abe
fix(seer): Replace internal Unicode operators with readable labels in…
Mihir-Mavalankar 39ea4be
ref(seer): Improve widget query hints for natural language agent
Mihir-Mavalankar 898ff84
ref(seer): Use OP_LABELS to dynamically replace wildcard operators
Mihir-Mavalankar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
142 changes: 132 additions & 10 deletions
142
static/app/views/dashboards/widgetCard/widgetLLMContext.spec.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,28 +1,150 @@ | ||
| import {DisplayType} from 'sentry/views/dashboards/types'; | ||
|
|
||
| import {getWidgetQueryLLMHint} from './widgetLLMContext'; | ||
| import {getWidgetQueryLLMHint, readableConditions} from './widgetLLMContext'; | ||
|
|
||
| describe('getWidgetQueryLLMHint', () => { | ||
| it.each([ | ||
| [DisplayType.LINE, 'timeseries'], | ||
| [DisplayType.AREA, 'timeseries'], | ||
| [DisplayType.BAR, 'timeseries'], | ||
| [DisplayType.LINE, 'timeseries chart'], | ||
| [DisplayType.AREA, 'timeseries chart'], | ||
| [DisplayType.BAR, 'timeseries chart'], | ||
| ])('returns timeseries hint for %s', (displayType, expected) => { | ||
| expect(getWidgetQueryLLMHint(displayType)).toContain(expected); | ||
| }); | ||
|
|
||
| it('returns table hint for TABLE', () => { | ||
| expect(getWidgetQueryLLMHint(DisplayType.TABLE)).toContain('table query'); | ||
| expect(getWidgetQueryLLMHint(DisplayType.TABLE)).toContain('shows a table'); | ||
| }); | ||
|
|
||
| it('returns single aggregate hint for BIG_NUMBER', () => { | ||
| expect(getWidgetQueryLLMHint(DisplayType.BIG_NUMBER)).toContain('single aggregate'); | ||
| it('returns single number hint for BIG_NUMBER', () => { | ||
| expect(getWidgetQueryLLMHint(DisplayType.BIG_NUMBER)).toContain('single number'); | ||
| expect(getWidgetQueryLLMHint(DisplayType.BIG_NUMBER)).toContain( | ||
| 'value is included below' | ||
| 'current value is included below' | ||
| ); | ||
| }); | ||
|
|
||
| it('returns table hint as default for unknown types', () => { | ||
| expect(getWidgetQueryLLMHint(DisplayType.WHEEL)).toContain('table query'); | ||
| it('returns generic hint for unknown types', () => { | ||
| expect(getWidgetQueryLLMHint(DisplayType.WHEEL)).toContain('shows data'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('readableConditions', () => { | ||
| it('replaces Contains operator with readable label', () => { | ||
| expect(readableConditions('span.name:\uf00dContains\uf00dfoo')).toBe( | ||
| 'span.name: contains foo' | ||
| ); | ||
| }); | ||
|
|
||
| it('replaces Contains with IN list brackets', () => { | ||
| expect(readableConditions('span.name:\uf00dContains\uf00d[a,b,c]')).toBe( | ||
| 'span.name: contains [a,b,c]' | ||
| ); | ||
| }); | ||
|
|
||
| it('replaces DoesNotContain operator', () => { | ||
| expect(readableConditions('key:\uf00dDoesNotContain\uf00dval')).toBe( | ||
| 'key: does not contain val' | ||
| ); | ||
| }); | ||
|
|
||
| it('replaces StartsWith and EndsWith operators', () => { | ||
| expect(readableConditions('key:\uf00dStartsWith\uf00d/api')).toBe( | ||
| 'key: starts with /api' | ||
| ); | ||
| expect(readableConditions('key:\uf00dEndsWith\uf00d.json')).toBe( | ||
| 'key: ends with .json' | ||
| ); | ||
| }); | ||
|
|
||
| it('replaces DoesNotStartWith and DoesNotEndWith operators', () => { | ||
| expect(readableConditions('key:\uf00dDoesNotStartWith\uf00d/api')).toBe( | ||
| 'key: does not start with /api' | ||
| ); | ||
| expect(readableConditions('key:\uf00dDoesNotEndWith\uf00d.json')).toBe( | ||
| 'key: does not end with .json' | ||
| ); | ||
| }); | ||
|
|
||
| it('preserves negated filter prefix', () => { | ||
| expect(readableConditions('!path:\uf00dContains\uf00dfoo')).toBe( | ||
| '!path: contains foo' | ||
| ); | ||
| }); | ||
|
|
||
| it('replaces multiple operators in one string', () => { | ||
| const input = | ||
| 'span.name:\uf00dContains\uf00dqueue.task !trigger_path:\uf00dContains\uf00dold_seer'; | ||
| expect(readableConditions(input)).toBe( | ||
| 'span.name: contains queue.task !trigger_path: contains old_seer' | ||
| ); | ||
| }); | ||
|
|
||
| it('passes through plain filters unchanged', () => { | ||
| expect(readableConditions('browser.name:Firefox')).toBe('browser.name:Firefox'); | ||
| }); | ||
|
|
||
| it('passes through free text unchanged', () => { | ||
| expect(readableConditions('some free text')).toBe('some free text'); | ||
| }); | ||
|
|
||
| it('passes through empty string', () => { | ||
| expect(readableConditions('')).toBe(''); | ||
| }); | ||
|
|
||
| it('preserves OR and parentheses', () => { | ||
| expect(readableConditions('(a:1 OR b:2) error')).toBe('(a:1 OR b:2) error'); | ||
| }); | ||
|
|
||
| it('preserves comparison operators', () => { | ||
| expect(readableConditions('count():>100 duration:<=5s')).toBe( | ||
| 'count():>100 duration:<=5s' | ||
| ); | ||
| }); | ||
|
|
||
| it('handles real-world widget query', () => { | ||
| const input = | ||
| 'span.description:\uf00dContains\uf00d[sentry.tasks.autofix.generate_issue_summary_only,sentry.tasks.autofix.run_automation_only_task] span.name:\uf00dContains\uf00dqueue.task.taskworker !trigger_path:\uf00dContains\uf00dold_seer_automation'; | ||
| expect(readableConditions(input)).toBe( | ||
| 'span.description: contains [sentry.tasks.autofix.generate_issue_summary_only,sentry.tasks.autofix.run_automation_only_task] span.name: contains queue.task.taskworker !trigger_path: contains old_seer_automation' | ||
| ); | ||
| }); | ||
|
|
||
| it('does not replace DoesNotContain partially as Contains', () => { | ||
| // DoesNotContain must be replaced before Contains to avoid partial match | ||
| expect(readableConditions('key:\uf00dDoesNotContain\uf00dval')).toBe( | ||
| 'key: does not contain val' | ||
| ); | ||
| // Should NOT produce "key: does not contains val" or "key:DoesNot contains val" | ||
| expect(readableConditions('key:\uf00dDoesNotContain\uf00dval')).not.toContain( | ||
| '\uf00d' | ||
| ); | ||
| }); | ||
|
|
||
| it('handles mixed operator types in one query', () => { | ||
| const input = | ||
| 'url:\uf00dStartsWith\uf00d/api span.description:\uf00dContains\uf00dfoo !path:\uf00dDoesNotEndWith\uf00d.js'; | ||
| expect(readableConditions(input)).toBe( | ||
| 'url: starts with /api span.description: contains foo !path: does not end with .js' | ||
| ); | ||
| }); | ||
|
|
||
| it('handles the same operator appearing multiple times', () => { | ||
| const input = | ||
| 'a:\uf00dContains\uf00dfoo b:\uf00dContains\uf00dbar c:\uf00dContains\uf00dbaz'; | ||
| expect(readableConditions(input)).toBe( | ||
| 'a: contains foo b: contains bar c: contains baz' | ||
| ); | ||
| }); | ||
|
|
||
| it('preserves OR with wildcard operators inside parens', () => { | ||
| const input = | ||
| '(span.name:\uf00dContains\uf00dfoo OR span.name:\uf00dContains\uf00dbar)'; | ||
| expect(readableConditions(input)).toBe( | ||
| '(span.name: contains foo OR span.name: contains bar)' | ||
| ); | ||
| }); | ||
|
|
||
| it('does not replace literal "Contains" text without unicode markers', () => { | ||
| // The word "Contains" in a value or free text should NOT be replaced | ||
| expect(readableConditions('message:Contains error')).toBe('message:Contains error'); | ||
| }); | ||
| }); |
29 changes: 23 additions & 6 deletions
29
static/app/views/dashboards/widgetCard/widgetLLMContext.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,37 @@ | ||
| import {DisplayType} from 'sentry/views/dashboards/types'; | ||
|
|
||
| /** | ||
| * Returns a hint for the Seer Explorer agent describing how to re-query this | ||
| * widget's data using a tool call, if the user wants to dig deeper. | ||
| * Replace internal \uf00d-delimited wildcard operators with readable labels | ||
| * so the Seer Explorer agent can understand widget filter conditions. | ||
| * | ||
| * All other query structure (AND, OR, parens, free text, comparison operators) | ||
| * passes through unchanged since only wildcard operators use \uf00d markers. | ||
| */ | ||
| export function readableConditions(query: string): string { | ||
| return query | ||
| .replaceAll('\uf00dDoesNotContain\uf00d', ' does not contain ') | ||
| .replaceAll('\uf00dDoesNotStartWith\uf00d', ' does not start with ') | ||
| .replaceAll('\uf00dDoesNotEndWith\uf00d', ' does not end with ') | ||
| .replaceAll('\uf00dContains\uf00d', ' contains ') | ||
| .replaceAll('\uf00dStartsWith\uf00d', ' starts with ') | ||
| .replaceAll('\uf00dEndsWith\uf00d', ' ends with '); | ||
| } | ||
|
|
||
| /** | ||
| * Returns a hint for the Seer Explorer agent describing what this widget | ||
| * visualizes so it can understand the intent from the query config. | ||
| */ | ||
| export function getWidgetQueryLLMHint(displayType: DisplayType): string { | ||
| switch (displayType) { | ||
| case DisplayType.LINE: | ||
| case DisplayType.AREA: | ||
| case DisplayType.BAR: | ||
| return 'To dig deeper into this widget, run a timeseries query using y_axes (aggregates) + group_by (columns) + query (conditions)'; | ||
| return 'This widget shows a timeseries chart. The aggregates are the y-axis metrics, columns are the group-by breakdowns, and conditions filter the data. Understand the intent from the query config below.'; | ||
| case DisplayType.TABLE: | ||
| return 'To dig deeper into this widget, run a table query using fields (aggregates + columns) + query (conditions) + sort (orderby)'; | ||
| return 'This widget shows a table. The aggregates and columns define the visible fields, orderby is the sort, and conditions filter the data. Understand the intent from the query config below.'; | ||
| case DisplayType.BIG_NUMBER: | ||
| return 'To dig deeper into this widget, run a single aggregate query using fields (aggregates) + query (conditions); current value is included below'; | ||
| return 'This widget shows a single number. The aggregate is the metric, conditions filter the data, and the current value is included below. Understand the intent from the query config below.'; | ||
| default: | ||
| return 'To dig deeper into this widget, run a table query using fields (aggregates + columns) + query (conditions)'; | ||
| return 'This widget shows data. The aggregates, columns, and conditions define what is displayed. Understand the intent from the query config below.'; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.