11import logging
22import re
33from collections import defaultdict
4- from collections .abc import Mapping
4+ from collections .abc import Mapping , Sequence
55from typing import Any , TypedDict
66
77import sentry_sdk
88from drf_spectacular .utils import OpenApiParameter , extend_schema
99from rest_framework .request import Request
1010from rest_framework .response import Response
1111
12+ from sentry import features
13+ from sentry .ai_monitoring .constants import AI_CONVERSATIONS_FIELDS
1214from sentry .ai_monitoring .conversation_titles import fetch_conversation_titles
1315from sentry .ai_monitoring .serializers import OrganizationAIConversationsSerializer
1416from sentry .ai_monitoring .utils import get_first_input_message , get_last_output , timestamp_to_float
@@ -236,7 +238,12 @@ def get(
236238 except NoProjects :
237239 return Response (status = 404 )
238240
239- serializer = OrganizationAIConversationsSerializer (data = request .GET )
241+ sorting_enabled = features .has (
242+ "organizations:gen-ai-conversations-querying-enhancements" , organization
243+ )
244+ serializer = OrganizationAIConversationsSerializer (
245+ data = request .GET , context = {"sorting_enabled" : sorting_enabled }
246+ )
240247 if not serializer .is_valid ():
241248 return Response (as_validation_errors (serializer ), status = 400 )
242249
@@ -249,6 +256,7 @@ def data_fn(offset: int, limit: int) -> list[AIConversationResponse]:
249256 limit = limit ,
250257 user_query = validated_data .get ("query" , "" ),
251258 sampling_mode = validated_data .get ("samplingMode" , "NORMAL" ),
259+ sorts = validated_data ["sort" ] if sorting_enabled else None ,
252260 )
253261
254262 with handle_query_errors ():
@@ -278,12 +286,13 @@ def _get_conversations(
278286 limit : int ,
279287 user_query : str ,
280288 sampling_mode : SAMPLING_MODES = "NORMAL" ,
289+ sorts : Sequence [str ] | None = None ,
281290 ) -> list [AIConversationResponse ]:
282291 base_filter = "has:gen_ai.conversation.id has:gen_ai.operation.type"
283292 query_string = _build_conversation_query (base_filter , user_query )
284293
285294 conversation_ids_results = self ._fetch_conversation_ids (
286- snuba_params , query_string , offset , limit , sampling_mode
295+ snuba_params , query_string , offset , limit , sampling_mode , sorts
287296 )
288297 conversation_ids = _extract_conversation_ids (conversation_ids_results )
289298
@@ -303,24 +312,45 @@ def _fetch_conversation_ids(
303312 offset : int ,
304313 limit : int ,
305314 sampling_mode : SAMPLING_MODES ,
315+ sorts : Sequence [str ] | None = None ,
306316 ) -> EAPResponse :
317+ selected_columns = ["gen_ai.conversation.id" , "max(precise.finish_ts)" ]
318+ orderby = ["-max(precise.finish_ts)" ]
319+ if sorts is not None :
320+ # Keep groups with missing sort attributes: EAP filters for the presence
321+ # of at least one selected aggregate attribute. Timestamp is always present.
322+ selected_columns = ["gen_ai.conversation.id" , "max(timestamp)" ]
323+ selected_aliases = set (selected_columns )
324+ orderby = []
325+ for sort in sorts :
326+ expression , alias = AI_CONVERSATIONS_FIELDS [sort .removeprefix ("-" )]
327+ orderby .append (("-" if sort .startswith ("-" ) else "" ) + alias )
328+ if alias not in selected_aliases :
329+ selected_columns .append (
330+ expression if expression == alias else f"{ expression } as { alias } "
331+ )
332+ selected_aliases .add (alias )
333+ if not any (column .removeprefix ("-" ) == "gen_ai.conversation.id" for column in orderby ):
334+ orderby .append ("gen_ai.conversation.id" )
335+
336+ # TODO (vgrozdanic): Sort on whole conversations instead of only matching spans.
307337 return Spans .run_table_query (
308338 params = snuba_params ,
309339 query_string = query_string ,
310- selected_columns = [ "gen_ai.conversation.id" , "max(precise.finish_ts)" ] ,
311- orderby = [ "-max(precise.finish_ts)" ] ,
340+ selected_columns = selected_columns ,
341+ orderby = orderby ,
312342 offset = offset ,
313343 limit = limit ,
314344 referrer = Referrer .API_AI_CONVERSATIONS .value ,
315- config = SearchResolverConfig (auto_fields = True ),
345+ config = SearchResolverConfig (auto_fields = True , disable_aggregate_extrapolation = True ),
316346 sampling_mode = sampling_mode ,
317347 )
318348
319349 @trace
320350 def _get_conversations_data (
321351 self , snuba_params : SnubaParams , conversation_ids : list [str ]
322352 ) -> list [AIConversationResponse ]:
323- config = SearchResolverConfig (auto_fields = True )
353+ config = SearchResolverConfig (auto_fields = True , disable_aggregate_extrapolation = True )
324354 resolver = Spans .get_resolver (snuba_params , config )
325355
326356 # Build queries
0 commit comments