From 9fd016cc9885b2ac729485199be7a18e71b073b6 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Mon, 3 Aug 2026 13:26:50 +0200 Subject: [PATCH 1/4] ref(ai-monitoring): Align list titles with detail selection Pick one title per conversation by earliest title_source_timestamp (project_id on ties), and keep the list endpoint up if metadata lookup fails. --- src/sentry/ai_monitoring/utils.py | 40 ++++++----- .../organization_ai_conversations.py | 46 ++++++------- tests/sentry/ai_monitoring/test_utils.py | 68 ++++++++++++++++--- .../test_organization_ai_conversations.py | 66 +++++++++++++++++- 4 files changed, 168 insertions(+), 52 deletions(-) diff --git a/src/sentry/ai_monitoring/utils.py b/src/sentry/ai_monitoring/utils.py index 272b9b8ded14..c8e2c5c17095 100644 --- a/src/sentry/ai_monitoring/utils.py +++ b/src/sentry/ai_monitoring/utils.py @@ -84,6 +84,10 @@ def clamp_conversation_id_for_storage(conversation_id: str) -> str: return conversation_id[:CONVERSATION_ID_TRUNCATE_TO] + "..." +# Earliest title source wins (closest to the first user message); project_id breaks ties. +TITLE_ORDER_BY = (F("title_source_timestamp").asc(nulls_last=True), "project_id") + + def fetch_conversation_title( conversation_id: str, project_ids: Collection[int], @@ -105,18 +109,18 @@ def fetch_conversation_title( title__isnull=False, ) .exclude(title="") - .order_by(F("title_source_timestamp").asc(nulls_last=True), "project_id") + .order_by(*TITLE_ORDER_BY) .first() ) def fetch_conversation_titles( conversation_project_pairs: Collection[tuple[str, int]], -) -> dict[tuple[str, int], str]: - """Look up stored titles for the given (conversation_id, project_id) pairs. +) -> dict[str, str]: + """One winning title per conversation_id among the given pairs. - A conversation id is only unique within a project, so callers must key on the - pair. Pairs without a titled row are simply absent from the result. + Same selection as ``fetch_conversation_title``. Only requested pairs participate; + untitled conversations are absent from the result. """ if not conversation_project_pairs: return {} @@ -127,19 +131,23 @@ def fetch_conversation_titles( for conversation_id, _ in requested_pairs } - rows = AIConversationMetadata.objects.filter( - project_id__in={project_id for _, project_id in requested_pairs}, - conversation_id_hash__in=conversation_id_by_hash, - title__isnull=False, - ).values_list("conversation_id_hash", "project_id", "title") + rows = ( + AIConversationMetadata.objects.filter( + project_id__in={project_id for _, project_id in requested_pairs}, + conversation_id_hash__in=conversation_id_by_hash, + title__isnull=False, + ) + .exclude(title="") + .order_by(*TITLE_ORDER_BY) + .values_list("conversation_id_hash", "project_id", "title") + ) - # The filter matches the cross product of the hashes and the projects, so drop - # any (conversation, project) combination the caller did not ask about. - titles: dict[tuple[str, int], str] = {} + # Filter is hashes × projects; drop unrequested pairs. Order makes setdefault win. + titles: dict[str, str] = {} for row_hash, project_id, title in rows: - pair = (conversation_id_by_hash[row_hash], project_id) - if title and pair in requested_pairs: - titles[pair] = title + conversation_id = conversation_id_by_hash[row_hash] + if (conversation_id, project_id) in requested_pairs: + titles.setdefault(conversation_id, title) return titles diff --git a/src/sentry/api/endpoints/organization_ai_conversations.py b/src/sentry/api/endpoints/organization_ai_conversations.py index b716ff7b41e4..2d170ca039e0 100644 --- a/src/sentry/api/endpoints/organization_ai_conversations.py +++ b/src/sentry/api/endpoints/organization_ai_conversations.py @@ -1,7 +1,7 @@ import logging import re from collections import defaultdict -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from datetime import datetime from typing import Any, TypedDict @@ -80,17 +80,6 @@ def _compute_timestamp_ms(finish_ts: float) -> int: return int(finish_ts * 1000) if finish_ts else 0 -def _first_title( - titles: Mapping[tuple[str, int], str], conv_id: str, project_ids: Sequence[int] -) -> str | None: - """Lowest project id with a stored title wins, so results are stable across requests.""" - for project_id in project_ids: - title = titles.get((conv_id, project_id)) - if title is not None: - return title - return None - - def _extract_first_user_message(messages: Any) -> str | None: """Extract first user message, handling both old (content) and new (parts) formats.""" if isinstance(messages, str) and messages == FILTERED: @@ -592,17 +581,24 @@ def _apply_titles( conversations_map: dict[str, dict[str, Any]], project_ids_by_conversation: Mapping[str, set[int]], ) -> None: - """Attach stored conversation titles, leaving `title` as None when we have none.""" - sorted_project_ids = { - conv_id: sorted(project_ids_by_conversation.get(conv_id, ())) + """Set each conversation's `title` from storage when present. + + On lookup failure, log and leave titles unset so the list response still succeeds. + """ + pairs = [ + (conv_id, project_id) for conv_id in conversations_map - } - titles = fetch_conversation_titles( - [ - (conv_id, project_id) - for conv_id, project_ids in sorted_project_ids.items() - for project_id in project_ids - ] - ) - for conv_id, conversation in conversations_map.items(): - conversation["title"] = _first_title(titles, conv_id, sorted_project_ids[conv_id]) + for project_id in project_ids_by_conversation.get(conv_id, ()) + ] + try: + titles = fetch_conversation_titles(pairs) + except Exception: + logger.exception( + "Failed to resolve titles for AI conversations", + extra={"project_ids": sorted({project_id for _, project_id in pairs})}, + ) + return + + for conv_id, title in titles.items(): + if conv_id in conversations_map: + conversations_map[conv_id]["title"] = title diff --git a/tests/sentry/ai_monitoring/test_utils.py b/tests/sentry/ai_monitoring/test_utils.py index 0d75ecff4b39..11d61da4899a 100644 --- a/tests/sentry/ai_monitoring/test_utils.py +++ b/tests/sentry/ai_monitoring/test_utils.py @@ -17,7 +17,7 @@ def test_returns_title_for_requested_pair(self) -> None: titles = fetch_conversation_titles([("conv-1", self.project.id)]) - assert titles == {("conv-1", self.project.id): "Reset my password"} + assert titles == {"conv-1": "Reset my password"} def test_skips_untitled_rows(self) -> None: self.create_ai_conversation_metadata( @@ -63,32 +63,80 @@ def test_does_not_return_pairs_that_were_not_requested(self) -> None: ) assert titles == { - ("conv-1", self.project.id): "Owned by project one", - ("conv-2", other_project.id): "Second conversation", + "conv-1": "Owned by project one", + "conv-2": "Second conversation", } - def test_returns_both_projects_when_both_requested(self) -> None: + def test_earliest_source_timestamp_wins(self) -> None: other_project = self.create_project(organization=self.organization) + self.create_ai_conversation_metadata( + project=self.project, + conversation_id="conv-1", + title="Later half of the conversation", + title_source_timestamp=datetime(2024, 5, 1, 12, 0, tzinfo=UTC), + ) + self.create_ai_conversation_metadata( + project=other_project, + conversation_id="conv-1", + title="Start of the conversation", + title_source_timestamp=datetime(2024, 5, 1, 11, 0, tzinfo=UTC), + ) + titles = fetch_conversation_titles( + [("conv-1", self.project.id), ("conv-1", other_project.id)] + ) + + assert titles == {"conv-1": "Start of the conversation"} + + def test_null_source_timestamp_loses(self) -> None: + other_project = self.create_project(organization=self.organization) self.create_ai_conversation_metadata( project=self.project, conversation_id="conv-1", - title="Owned by project one", + title="Unknown when this started", + title_source_timestamp=None, ) self.create_ai_conversation_metadata( project=other_project, conversation_id="conv-1", - title="Owned by project two", + title="Start of the conversation", + title_source_timestamp=datetime(2024, 5, 1, 11, 0, tzinfo=UTC), ) titles = fetch_conversation_titles( [("conv-1", self.project.id), ("conv-1", other_project.id)] ) - assert titles == { - ("conv-1", self.project.id): "Owned by project one", - ("conv-1", other_project.id): "Owned by project two", - } + assert titles == {"conv-1": "Start of the conversation"} + + def test_ties_break_on_project_id(self) -> None: + source_timestamp = datetime(2024, 5, 1, tzinfo=UTC) + lower_project, higher_project = sorted( + ( + self.create_project(organization=self.organization), + self.create_project(organization=self.organization), + ), + key=lambda project: project.id, + ) + + self.create_ai_conversation_metadata( + project=lower_project, + conversation_id="conv-1", + title="Lower project id", + title_source_timestamp=source_timestamp, + ) + self.create_ai_conversation_metadata( + project=higher_project, + conversation_id="conv-1", + title="Higher project id", + title_source_timestamp=source_timestamp, + ) + + titles = fetch_conversation_titles( + [("conv-1", lower_project.id), ("conv-1", higher_project.id)] + ) + + assert titles == {"conv-1": "Lower project id"} class FetchConversationTitleTest(TestCase): diff --git a/tests/sentry/api/endpoints/test_organization_ai_conversations.py b/tests/sentry/api/endpoints/test_organization_ai_conversations.py index 2362e91af3cc..4f03bd6abe30 100644 --- a/tests/sentry/api/endpoints/test_organization_ai_conversations.py +++ b/tests/sentry/api/endpoints/test_organization_ai_conversations.py @@ -1,6 +1,7 @@ import json # noqa: S003 from datetime import timedelta from typing import Any +from unittest.mock import MagicMock, patch from uuid import uuid4 from django.urls import reverse @@ -1527,7 +1528,7 @@ def test_title_not_taken_from_unrelated_project(self) -> None: assert response.data[0]["title"] is None def test_title_for_conversation_spanning_projects(self) -> None: - """When a conversation spans projects, the lowest project id with a title wins.""" + """Equal source timestamps: lowest project id breaks the tie.""" now = before_now(days=25).replace(microsecond=0) conversation_id = uuid4().hex lower_project = self.create_project(organization=self.organization) @@ -1565,6 +1566,45 @@ def test_title_for_conversation_spanning_projects(self) -> None: assert len(response.data) == 1 assert response.data[0]["title"] == "Lower project id title" + def test_title_earliest_source_timestamp_wins_across_projects(self) -> None: + """Across projects, earliest title_source_timestamp wins (not lowest project id).""" + now = before_now(days=25).replace(microsecond=0) + conversation_id = uuid4().hex + lower_project = self.create_project(organization=self.organization) + higher_project = self.create_project(organization=self.organization) + assert lower_project.id < higher_project.id + + self._store_conversation_span( + conversation_id, now - timedelta(seconds=2), project=lower_project + ) + self._store_conversation_span( + conversation_id, now - timedelta(seconds=1), project=higher_project + ) + + self.create_ai_conversation_metadata( + project=lower_project, + conversation_id=conversation_id, + title="Later titled project", + title_source_timestamp=now - timedelta(seconds=1), + ) + self.create_ai_conversation_metadata( + project=higher_project, + conversation_id=conversation_id, + title="Earlier titled project", + title_source_timestamp=now - timedelta(seconds=2), + ) + + query = { + "project": [lower_project.id, higher_project.id], + "start": (now - timedelta(hours=1)).isoformat(), + "end": (now + timedelta(hours=1)).isoformat(), + } + + response = self.do_request(query) + assert response.status_code == 200, response.data + assert len(response.data) == 1 + assert response.data[0]["title"] == "Earlier titled project" + def test_title_found_when_only_higher_project_id_has_one(self) -> None: """Every project the conversation spans is searched, not just the lowest.""" now = before_now(days=25).replace(microsecond=0) @@ -1597,3 +1637,27 @@ def test_title_found_when_only_higher_project_id_has_one(self) -> None: assert response.status_code == 200, response.data assert len(response.data) == 1 assert response.data[0]["title"] == "Higher project id title" + + @patch( + "sentry.api.endpoints.organization_ai_conversations.fetch_conversation_titles", + side_effect=Exception("metadata unavailable"), + ) + def test_title_lookup_failure_does_not_break_list( + self, mock_fetch_conversation_titles: MagicMock + ) -> None: + now = before_now(days=25).replace(microsecond=0) + conversation_id = uuid4().hex + + self._store_conversation_span(conversation_id, now) + + query = { + "project": [self.project.id], + "start": (now - timedelta(hours=1)).isoformat(), + "end": (now + timedelta(hours=1)).isoformat(), + } + + response = self.do_request(query) + assert response.status_code == 200, response.data + assert len(response.data) == 1 + assert response.data[0]["title"] is None + assert mock_fetch_conversation_titles.call_count == 1 From 87c8f813d2b48fc74e6f31be2adfcbc52bc7f14a Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Mon, 3 Aug 2026 14:53:41 +0200 Subject: [PATCH 2/4] ref(ai-monitoring): Keep conversation titles keyed by project Conversation ids are only unique within a project, so bulk title lookup returns per (conversation_id, project_id) again. List still picks a stable display title and survives metadata lookup failures. --- src/sentry/ai_monitoring/utils.py | 20 +++--- .../organization_ai_conversations.py | 26 +++++-- tests/sentry/ai_monitoring/test_utils.py | 68 +++---------------- .../test_organization_ai_conversations.py | 41 +---------- 4 files changed, 41 insertions(+), 114 deletions(-) diff --git a/src/sentry/ai_monitoring/utils.py b/src/sentry/ai_monitoring/utils.py index c8e2c5c17095..d51083db5079 100644 --- a/src/sentry/ai_monitoring/utils.py +++ b/src/sentry/ai_monitoring/utils.py @@ -116,11 +116,11 @@ def fetch_conversation_title( def fetch_conversation_titles( conversation_project_pairs: Collection[tuple[str, int]], -) -> dict[str, str]: - """One winning title per conversation_id among the given pairs. +) -> dict[tuple[str, int], str]: + """Look up stored titles for the given (conversation_id, project_id) pairs. - Same selection as ``fetch_conversation_title``. Only requested pairs participate; - untitled conversations are absent from the result. + A conversation id is only unique within a project, so callers must key on the + pair. Pairs without a titled row are simply absent from the result. """ if not conversation_project_pairs: return {} @@ -138,16 +138,16 @@ def fetch_conversation_titles( title__isnull=False, ) .exclude(title="") - .order_by(*TITLE_ORDER_BY) .values_list("conversation_id_hash", "project_id", "title") ) - # Filter is hashes × projects; drop unrequested pairs. Order makes setdefault win. - titles: dict[str, str] = {} + # The filter matches the cross product of the hashes and the projects, so drop + # any (conversation, project) combination the caller did not ask about. + titles: dict[tuple[str, int], str] = {} for row_hash, project_id, title in rows: - conversation_id = conversation_id_by_hash[row_hash] - if (conversation_id, project_id) in requested_pairs: - titles.setdefault(conversation_id, title) + pair = (conversation_id_by_hash[row_hash], project_id) + if pair in requested_pairs: + titles[pair] = title return titles diff --git a/src/sentry/api/endpoints/organization_ai_conversations.py b/src/sentry/api/endpoints/organization_ai_conversations.py index 2d170ca039e0..9e7920651eb9 100644 --- a/src/sentry/api/endpoints/organization_ai_conversations.py +++ b/src/sentry/api/endpoints/organization_ai_conversations.py @@ -1,7 +1,7 @@ import logging import re from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import datetime from typing import Any, TypedDict @@ -80,6 +80,17 @@ def _compute_timestamp_ms(finish_ts: float) -> int: return int(finish_ts * 1000) if finish_ts else 0 +def _first_title( + titles: Mapping[tuple[str, int], str], conv_id: str, project_ids: Sequence[int] +) -> str | None: + """Lowest project id with a stored title wins, so results are stable across requests.""" + for project_id in project_ids: + title = titles.get((conv_id, project_id)) + if title is not None: + return title + return None + + def _extract_first_user_message(messages: Any) -> str | None: """Extract first user message, handling both old (content) and new (parts) formats.""" if isinstance(messages, str) and messages == FILTERED: @@ -585,10 +596,14 @@ def _apply_titles( On lookup failure, log and leave titles unset so the list response still succeeds. """ + sorted_project_ids = { + conv_id: sorted(project_ids_by_conversation.get(conv_id, ())) + for conv_id in conversations_map + } pairs = [ (conv_id, project_id) - for conv_id in conversations_map - for project_id in project_ids_by_conversation.get(conv_id, ()) + for conv_id, project_ids in sorted_project_ids.items() + for project_id in project_ids ] try: titles = fetch_conversation_titles(pairs) @@ -599,6 +614,5 @@ def _apply_titles( ) return - for conv_id, title in titles.items(): - if conv_id in conversations_map: - conversations_map[conv_id]["title"] = title + for conv_id, conversation in conversations_map.items(): + conversation["title"] = _first_title(titles, conv_id, sorted_project_ids[conv_id]) diff --git a/tests/sentry/ai_monitoring/test_utils.py b/tests/sentry/ai_monitoring/test_utils.py index 11d61da4899a..0d75ecff4b39 100644 --- a/tests/sentry/ai_monitoring/test_utils.py +++ b/tests/sentry/ai_monitoring/test_utils.py @@ -17,7 +17,7 @@ def test_returns_title_for_requested_pair(self) -> None: titles = fetch_conversation_titles([("conv-1", self.project.id)]) - assert titles == {"conv-1": "Reset my password"} + assert titles == {("conv-1", self.project.id): "Reset my password"} def test_skips_untitled_rows(self) -> None: self.create_ai_conversation_metadata( @@ -63,80 +63,32 @@ def test_does_not_return_pairs_that_were_not_requested(self) -> None: ) assert titles == { - "conv-1": "Owned by project one", - "conv-2": "Second conversation", + ("conv-1", self.project.id): "Owned by project one", + ("conv-2", other_project.id): "Second conversation", } - def test_earliest_source_timestamp_wins(self) -> None: + def test_returns_both_projects_when_both_requested(self) -> None: other_project = self.create_project(organization=self.organization) - self.create_ai_conversation_metadata( - project=self.project, - conversation_id="conv-1", - title="Later half of the conversation", - title_source_timestamp=datetime(2024, 5, 1, 12, 0, tzinfo=UTC), - ) - self.create_ai_conversation_metadata( - project=other_project, - conversation_id="conv-1", - title="Start of the conversation", - title_source_timestamp=datetime(2024, 5, 1, 11, 0, tzinfo=UTC), - ) - titles = fetch_conversation_titles( - [("conv-1", self.project.id), ("conv-1", other_project.id)] - ) - - assert titles == {"conv-1": "Start of the conversation"} - - def test_null_source_timestamp_loses(self) -> None: - other_project = self.create_project(organization=self.organization) self.create_ai_conversation_metadata( project=self.project, conversation_id="conv-1", - title="Unknown when this started", - title_source_timestamp=None, + title="Owned by project one", ) self.create_ai_conversation_metadata( project=other_project, conversation_id="conv-1", - title="Start of the conversation", - title_source_timestamp=datetime(2024, 5, 1, 11, 0, tzinfo=UTC), + title="Owned by project two", ) titles = fetch_conversation_titles( [("conv-1", self.project.id), ("conv-1", other_project.id)] ) - assert titles == {"conv-1": "Start of the conversation"} - - def test_ties_break_on_project_id(self) -> None: - source_timestamp = datetime(2024, 5, 1, tzinfo=UTC) - lower_project, higher_project = sorted( - ( - self.create_project(organization=self.organization), - self.create_project(organization=self.organization), - ), - key=lambda project: project.id, - ) - - self.create_ai_conversation_metadata( - project=lower_project, - conversation_id="conv-1", - title="Lower project id", - title_source_timestamp=source_timestamp, - ) - self.create_ai_conversation_metadata( - project=higher_project, - conversation_id="conv-1", - title="Higher project id", - title_source_timestamp=source_timestamp, - ) - - titles = fetch_conversation_titles( - [("conv-1", lower_project.id), ("conv-1", higher_project.id)] - ) - - assert titles == {"conv-1": "Lower project id"} + assert titles == { + ("conv-1", self.project.id): "Owned by project one", + ("conv-1", other_project.id): "Owned by project two", + } class FetchConversationTitleTest(TestCase): diff --git a/tests/sentry/api/endpoints/test_organization_ai_conversations.py b/tests/sentry/api/endpoints/test_organization_ai_conversations.py index 4f03bd6abe30..0d83d7bdf4c9 100644 --- a/tests/sentry/api/endpoints/test_organization_ai_conversations.py +++ b/tests/sentry/api/endpoints/test_organization_ai_conversations.py @@ -1528,7 +1528,7 @@ def test_title_not_taken_from_unrelated_project(self) -> None: assert response.data[0]["title"] is None def test_title_for_conversation_spanning_projects(self) -> None: - """Equal source timestamps: lowest project id breaks the tie.""" + """When a conversation spans projects, the lowest project id with a title wins.""" now = before_now(days=25).replace(microsecond=0) conversation_id = uuid4().hex lower_project = self.create_project(organization=self.organization) @@ -1566,45 +1566,6 @@ def test_title_for_conversation_spanning_projects(self) -> None: assert len(response.data) == 1 assert response.data[0]["title"] == "Lower project id title" - def test_title_earliest_source_timestamp_wins_across_projects(self) -> None: - """Across projects, earliest title_source_timestamp wins (not lowest project id).""" - now = before_now(days=25).replace(microsecond=0) - conversation_id = uuid4().hex - lower_project = self.create_project(organization=self.organization) - higher_project = self.create_project(organization=self.organization) - assert lower_project.id < higher_project.id - - self._store_conversation_span( - conversation_id, now - timedelta(seconds=2), project=lower_project - ) - self._store_conversation_span( - conversation_id, now - timedelta(seconds=1), project=higher_project - ) - - self.create_ai_conversation_metadata( - project=lower_project, - conversation_id=conversation_id, - title="Later titled project", - title_source_timestamp=now - timedelta(seconds=1), - ) - self.create_ai_conversation_metadata( - project=higher_project, - conversation_id=conversation_id, - title="Earlier titled project", - title_source_timestamp=now - timedelta(seconds=2), - ) - - query = { - "project": [lower_project.id, higher_project.id], - "start": (now - timedelta(hours=1)).isoformat(), - "end": (now + timedelta(hours=1)).isoformat(), - } - - response = self.do_request(query) - assert response.status_code == 200, response.data - assert len(response.data) == 1 - assert response.data[0]["title"] == "Earlier titled project" - def test_title_found_when_only_higher_project_id_has_one(self) -> None: """Every project the conversation spans is searched, not just the lowest.""" now = before_now(days=25).replace(microsecond=0) From 394a20677289011198a20e967c202fa2204e20b6 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Mon, 3 Aug 2026 22:28:56 +0200 Subject: [PATCH 3/4] ref(ai-monitoring): Simplify list conversation title lookup Bulk titles are keyed by conversation_id over the request project scope, using the same earliest-source selection as detail. Drop pair maps and endpoint-side title picking; list lookup failures stay non-fatal. --- src/sentry/ai_monitoring/utils.py | 71 ++++++------ .../organization_ai_conversations.py | 51 +++------ tests/sentry/ai_monitoring/test_utils.py | 102 ++++++++++++------ .../test_organization_ai_conversations.py | 41 ++++++- 4 files changed, 157 insertions(+), 108 deletions(-) diff --git a/src/sentry/ai_monitoring/utils.py b/src/sentry/ai_monitoring/utils.py index d51083db5079..e94d0307c153 100644 --- a/src/sentry/ai_monitoring/utils.py +++ b/src/sentry/ai_monitoring/utils.py @@ -88,6 +88,39 @@ def clamp_conversation_id_for_storage(conversation_id: str) -> str: TITLE_ORDER_BY = (F("title_source_timestamp").asc(nulls_last=True), "project_id") +def fetch_conversation_titles( + conversation_ids: Collection[str], + project_ids: Collection[int], +) -> dict[str, str]: + """One title per conversation_id among the given projects. + + Earliest ``title_source_timestamp`` wins; ``project_id`` breaks ties. + """ + if not conversation_ids or not project_ids: + return {} + + conversation_id_by_hash = { + conversation_id_hash(conversation_id): conversation_id + for conversation_id in conversation_ids + } + + rows = ( + AIConversationMetadata.objects.filter( + project_id__in=set(project_ids), + conversation_id_hash__in=conversation_id_by_hash, + title__isnull=False, + ) + .exclude(title="") + .order_by(*TITLE_ORDER_BY) + .values_list("conversation_id_hash", "title") + ) + + titles: dict[str, str] = {} + for row_hash, title in rows: + titles.setdefault(conversation_id_by_hash[row_hash], title) + return titles + + def fetch_conversation_title( conversation_id: str, project_ids: Collection[int], @@ -114,44 +147,6 @@ def fetch_conversation_title( ) -def fetch_conversation_titles( - conversation_project_pairs: Collection[tuple[str, int]], -) -> dict[tuple[str, int], str]: - """Look up stored titles for the given (conversation_id, project_id) pairs. - - A conversation id is only unique within a project, so callers must key on the - pair. Pairs without a titled row are simply absent from the result. - """ - if not conversation_project_pairs: - return {} - - requested_pairs = set(conversation_project_pairs) - conversation_id_by_hash = { - conversation_id_hash(conversation_id): conversation_id - for conversation_id, _ in requested_pairs - } - - rows = ( - AIConversationMetadata.objects.filter( - project_id__in={project_id for _, project_id in requested_pairs}, - conversation_id_hash__in=conversation_id_by_hash, - title__isnull=False, - ) - .exclude(title="") - .values_list("conversation_id_hash", "project_id", "title") - ) - - # The filter matches the cross product of the hashes and the projects, so drop - # any (conversation, project) combination the caller did not ask about. - titles: dict[tuple[str, int], str] = {} - for row_hash, project_id, title in rows: - pair = (conversation_id_by_hash[row_hash], project_id) - if pair in requested_pairs: - titles[pair] = title - - return titles - - def _extract_first_user_message(messages: Any) -> str | None: if isinstance(messages, str) and messages == FILTERED: return None diff --git a/src/sentry/api/endpoints/organization_ai_conversations.py b/src/sentry/api/endpoints/organization_ai_conversations.py index 9e7920651eb9..0a17ecad28b7 100644 --- a/src/sentry/api/endpoints/organization_ai_conversations.py +++ b/src/sentry/api/endpoints/organization_ai_conversations.py @@ -1,7 +1,7 @@ import logging import re from collections import defaultdict -from collections.abc import Mapping, Sequence +from collections.abc import Collection from datetime import datetime from typing import Any, TypedDict @@ -80,17 +80,6 @@ def _compute_timestamp_ms(finish_ts: float) -> int: return int(finish_ts * 1000) if finish_ts else 0 -def _first_title( - titles: Mapping[tuple[str, int], str], conv_id: str, project_ids: Sequence[int] -) -> str | None: - """Lowest project id with a stored title wins, so results are stable across requests.""" - for project_id in project_ids: - title = titles.get((conv_id, project_id)) - if title is not None: - return title - return None - - def _extract_first_user_message(messages: Any) -> str | None: """Extract first user message, handling both old (content) and new (parts) formats.""" if isinstance(messages, str) and messages == FILTERED: @@ -332,11 +321,9 @@ def _get_conversations_data(self, snuba_params, conversation_ids: list[str]) -> # Process results with start_span(op="ai_conversations.process", name="Process query results"): conversations_map = self._build_conversations_from_aggregations(results["aggregations"]) - project_ids_by_conversation = self._apply_enrichment( - conversations_map, results["enrichment"] - ) + self._apply_enrichment(conversations_map, results["enrichment"]) self._apply_first_last_io(conversations_map, results["first_last_io"]) - self._apply_titles(conversations_map, project_ids_by_conversation) + self._apply_titles(conversations_map, snuba_params.project_ids) return [ conversations_map[conv_id] @@ -479,8 +466,8 @@ def _build_conversations_from_aggregations( def _apply_enrichment( self, conversations_map: dict[str, dict[str, Any]], enrichment_data: EAPResponse - ) -> dict[str, set[int]]: - """Apply enrichment data, returning the project ids each conversation spans.""" + ) -> None: + """Apply enrichment data from span rows onto each conversation.""" with start_span( op="ai_conversations.apply_enrichment", name="Apply enrichment data", @@ -492,7 +479,6 @@ def _apply_enrichment( traces_by_conversation: dict[str, set[str]] = defaultdict(set) tool_names_by_conversation: dict[str, set[str]] = defaultdict(set) tool_errors_by_conversation: dict[str, int] = defaultdict(int) - project_ids_by_conversation: dict[str, set[int]] = defaultdict(set) # Rows are sorted by timestamp, so the first occurrence per conversation # is the earliest span. Track the first span's user and project. user_by_conversation: dict[str, UserResponse] = {} @@ -504,10 +490,8 @@ def _apply_enrichment( continue project_id = row.get("project.id") - if isinstance(project_id, int): - project_ids_by_conversation[conv_id].add(project_id) - if conv_id not in first_project_by_conversation: - first_project_by_conversation[conv_id] = project_id + if isinstance(project_id, int) and conv_id not in first_project_by_conversation: + first_project_by_conversation[conv_id] = project_id trace_id = row.get("trace", "") if trace_id: @@ -547,8 +531,6 @@ def _apply_enrichment( conversation["toolErrors"] = tool_errors_by_conversation.get(conv_id, 0) conversation["projectId"] = first_project_by_conversation.get(conv_id) - return project_ids_by_conversation - def _apply_first_last_io( self, conversations_map: dict[str, dict[str, Any]], first_last_io_data: EAPResponse ) -> None: @@ -590,29 +572,20 @@ def _apply_first_last_io( def _apply_titles( self, conversations_map: dict[str, dict[str, Any]], - project_ids_by_conversation: Mapping[str, set[int]], + project_ids: Collection[int], ) -> None: """Set each conversation's `title` from storage when present. On lookup failure, log and leave titles unset so the list response still succeeds. """ - sorted_project_ids = { - conv_id: sorted(project_ids_by_conversation.get(conv_id, ())) - for conv_id in conversations_map - } - pairs = [ - (conv_id, project_id) - for conv_id, project_ids in sorted_project_ids.items() - for project_id in project_ids - ] try: - titles = fetch_conversation_titles(pairs) + titles = fetch_conversation_titles(conversations_map.keys(), project_ids) except Exception: logger.exception( "Failed to resolve titles for AI conversations", - extra={"project_ids": sorted({project_id for _, project_id in pairs})}, + extra={"project_ids": sorted(project_ids)}, ) return - for conv_id, conversation in conversations_map.items(): - conversation["title"] = _first_title(titles, conv_id, sorted_project_ids[conv_id]) + for conv_id, title in titles.items(): + conversations_map[conv_id]["title"] = title diff --git a/tests/sentry/ai_monitoring/test_utils.py b/tests/sentry/ai_monitoring/test_utils.py index 0d75ecff4b39..5e3ad61b9cd5 100644 --- a/tests/sentry/ai_monitoring/test_utils.py +++ b/tests/sentry/ai_monitoring/test_utils.py @@ -5,19 +5,20 @@ class FetchConversationTitlesTest(TestCase): - def test_returns_empty_for_no_pairs(self) -> None: - assert fetch_conversation_titles([]) == {} + def test_returns_empty_without_ids_or_projects(self) -> None: + assert fetch_conversation_titles([], [self.project.id]) == {} + assert fetch_conversation_titles(["conv-1"], []) == {} - def test_returns_title_for_requested_pair(self) -> None: + def test_returns_title_for_requested_conversation(self) -> None: self.create_ai_conversation_metadata( project=self.project, conversation_id="conv-1", title="Reset my password", ) - titles = fetch_conversation_titles([("conv-1", self.project.id)]) + titles = fetch_conversation_titles(["conv-1"], [self.project.id]) - assert titles == {("conv-1", self.project.id): "Reset my password"} + assert titles == {"conv-1": "Reset my password"} def test_skips_untitled_rows(self) -> None: self.create_ai_conversation_metadata( @@ -26,7 +27,7 @@ def test_skips_untitled_rows(self) -> None: title=None, ) - assert fetch_conversation_titles([("conv-1", self.project.id)]) == {} + assert fetch_conversation_titles(["conv-1"], [self.project.id]) == {} def test_skips_unknown_conversations(self) -> None: self.create_ai_conversation_metadata( @@ -35,60 +36,101 @@ def test_skips_unknown_conversations(self) -> None: title="Reset my password", ) - assert fetch_conversation_titles([("conv-2", self.project.id)]) == {} + assert fetch_conversation_titles(["conv-2"], [self.project.id]) == {} - def test_does_not_return_pairs_that_were_not_requested(self) -> None: - """A row matching the queried hashes and projects, but not as a requested pair.""" + def test_ignores_projects_outside_scope(self) -> None: other_project = self.create_project(organization=self.organization) + self.create_ai_conversation_metadata( + project=other_project, + conversation_id="conv-1", + title="Owned by project two", + ) + + assert fetch_conversation_titles(["conv-1"], [self.project.id]) == {} + def test_earliest_source_timestamp_wins(self) -> None: + other_project = self.create_project(organization=self.organization) self.create_ai_conversation_metadata( project=self.project, conversation_id="conv-1", - title="Owned by project one", + title="Later half of the conversation", + title_source_timestamp=datetime(2024, 5, 1, 12, 0, tzinfo=UTC), ) self.create_ai_conversation_metadata( project=other_project, conversation_id="conv-1", - title="Owned by project two", + title="Start of the conversation", + title_source_timestamp=datetime(2024, 5, 1, 11, 0, tzinfo=UTC), + ) + + titles = fetch_conversation_titles(["conv-1"], [self.project.id, other_project.id]) + + assert titles == {"conv-1": "Start of the conversation"} + + def test_null_source_timestamp_loses(self) -> None: + other_project = self.create_project(organization=self.organization) + self.create_ai_conversation_metadata( + project=self.project, + conversation_id="conv-1", + title="Unknown when this started", + title_source_timestamp=None, ) self.create_ai_conversation_metadata( project=other_project, - conversation_id="conv-2", - title="Second conversation", + conversation_id="conv-1", + title="Start of the conversation", + title_source_timestamp=datetime(2024, 5, 1, 11, 0, tzinfo=UTC), ) - # conv-1 is only asked about for self.project, conv-2 only for other_project. - titles = fetch_conversation_titles( - [("conv-1", self.project.id), ("conv-2", other_project.id)] + titles = fetch_conversation_titles(["conv-1"], [self.project.id, other_project.id]) + + assert titles == {"conv-1": "Start of the conversation"} + + def test_ties_break_on_project_id(self) -> None: + source_timestamp = datetime(2024, 5, 1, tzinfo=UTC) + lower_project, higher_project = sorted( + ( + self.create_project(organization=self.organization), + self.create_project(organization=self.organization), + ), + key=lambda project: project.id, + ) + + self.create_ai_conversation_metadata( + project=lower_project, + conversation_id="conv-1", + title="Lower project id", + title_source_timestamp=source_timestamp, + ) + self.create_ai_conversation_metadata( + project=higher_project, + conversation_id="conv-1", + title="Higher project id", + title_source_timestamp=source_timestamp, ) - assert titles == { - ("conv-1", self.project.id): "Owned by project one", - ("conv-2", other_project.id): "Second conversation", - } + titles = fetch_conversation_titles(["conv-1"], [lower_project.id, higher_project.id]) - def test_returns_both_projects_when_both_requested(self) -> None: - other_project = self.create_project(organization=self.organization) + assert titles == {"conv-1": "Lower project id"} + def test_multiple_conversations(self) -> None: + other_project = self.create_project(organization=self.organization) self.create_ai_conversation_metadata( project=self.project, conversation_id="conv-1", - title="Owned by project one", + title="First", ) self.create_ai_conversation_metadata( project=other_project, - conversation_id="conv-1", - title="Owned by project two", + conversation_id="conv-2", + title="Second", ) titles = fetch_conversation_titles( - [("conv-1", self.project.id), ("conv-1", other_project.id)] + ["conv-1", "conv-2"], [self.project.id, other_project.id] ) - assert titles == { - ("conv-1", self.project.id): "Owned by project one", - ("conv-1", other_project.id): "Owned by project two", - } + assert titles == {"conv-1": "First", "conv-2": "Second"} class FetchConversationTitleTest(TestCase): diff --git a/tests/sentry/api/endpoints/test_organization_ai_conversations.py b/tests/sentry/api/endpoints/test_organization_ai_conversations.py index 0d83d7bdf4c9..4f03bd6abe30 100644 --- a/tests/sentry/api/endpoints/test_organization_ai_conversations.py +++ b/tests/sentry/api/endpoints/test_organization_ai_conversations.py @@ -1528,7 +1528,7 @@ def test_title_not_taken_from_unrelated_project(self) -> None: assert response.data[0]["title"] is None def test_title_for_conversation_spanning_projects(self) -> None: - """When a conversation spans projects, the lowest project id with a title wins.""" + """Equal source timestamps: lowest project id breaks the tie.""" now = before_now(days=25).replace(microsecond=0) conversation_id = uuid4().hex lower_project = self.create_project(organization=self.organization) @@ -1566,6 +1566,45 @@ def test_title_for_conversation_spanning_projects(self) -> None: assert len(response.data) == 1 assert response.data[0]["title"] == "Lower project id title" + def test_title_earliest_source_timestamp_wins_across_projects(self) -> None: + """Across projects, earliest title_source_timestamp wins (not lowest project id).""" + now = before_now(days=25).replace(microsecond=0) + conversation_id = uuid4().hex + lower_project = self.create_project(organization=self.organization) + higher_project = self.create_project(organization=self.organization) + assert lower_project.id < higher_project.id + + self._store_conversation_span( + conversation_id, now - timedelta(seconds=2), project=lower_project + ) + self._store_conversation_span( + conversation_id, now - timedelta(seconds=1), project=higher_project + ) + + self.create_ai_conversation_metadata( + project=lower_project, + conversation_id=conversation_id, + title="Later titled project", + title_source_timestamp=now - timedelta(seconds=1), + ) + self.create_ai_conversation_metadata( + project=higher_project, + conversation_id=conversation_id, + title="Earlier titled project", + title_source_timestamp=now - timedelta(seconds=2), + ) + + query = { + "project": [lower_project.id, higher_project.id], + "start": (now - timedelta(hours=1)).isoformat(), + "end": (now + timedelta(hours=1)).isoformat(), + } + + response = self.do_request(query) + assert response.status_code == 200, response.data + assert len(response.data) == 1 + assert response.data[0]["title"] == "Earlier titled project" + def test_title_found_when_only_higher_project_id_has_one(self) -> None: """Every project the conversation spans is searched, not just the lowest.""" now = before_now(days=25).replace(microsecond=0) From 62e19853bffbd37348d4ef228844f0397fbff7d0 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Mon, 3 Aug 2026 22:38:25 +0200 Subject: [PATCH 4/4] fix(ai-monitoring): Scope list titles to conversation projects Bulk title lookup only considers (conversation_id, project_id) pairs from span enrichment, so a same id in another request project cannot supply the title. Also fix setdefault typing for nullable title columns. --- src/sentry/ai_monitoring/utils.py | 25 ++++--- .../organization_ai_conversations.py | 32 ++++++--- tests/sentry/ai_monitoring/test_utils.py | 65 +++++++++++++------ 3 files changed, 82 insertions(+), 40 deletions(-) diff --git a/src/sentry/ai_monitoring/utils.py b/src/sentry/ai_monitoring/utils.py index e94d0307c153..db207035ee35 100644 --- a/src/sentry/ai_monitoring/utils.py +++ b/src/sentry/ai_monitoring/utils.py @@ -89,35 +89,40 @@ def clamp_conversation_id_for_storage(conversation_id: str) -> str: def fetch_conversation_titles( - conversation_ids: Collection[str], - project_ids: Collection[int], + conversation_project_pairs: Collection[tuple[str, int]], ) -> dict[str, str]: - """One title per conversation_id among the given projects. + """One title per conversation_id among the given (conversation_id, project_id) pairs. - Earliest ``title_source_timestamp`` wins; ``project_id`` breaks ties. + Only requested pairs are considered (ids are unique per project). Among those, + earliest ``title_source_timestamp`` wins; ``project_id`` breaks ties. """ - if not conversation_ids or not project_ids: + if not conversation_project_pairs: return {} + requested_pairs = set(conversation_project_pairs) conversation_id_by_hash = { conversation_id_hash(conversation_id): conversation_id - for conversation_id in conversation_ids + for conversation_id, _ in requested_pairs } rows = ( AIConversationMetadata.objects.filter( - project_id__in=set(project_ids), + project_id__in={project_id for _, project_id in requested_pairs}, conversation_id_hash__in=conversation_id_by_hash, title__isnull=False, ) .exclude(title="") .order_by(*TITLE_ORDER_BY) - .values_list("conversation_id_hash", "title") + .values_list("conversation_id_hash", "project_id", "title") ) titles: dict[str, str] = {} - for row_hash, title in rows: - titles.setdefault(conversation_id_by_hash[row_hash], title) + for row_hash, project_id, title in rows: + if title is None: + continue + conversation_id = conversation_id_by_hash[row_hash] + if (conversation_id, project_id) in requested_pairs: + titles.setdefault(conversation_id, title) return titles diff --git a/src/sentry/api/endpoints/organization_ai_conversations.py b/src/sentry/api/endpoints/organization_ai_conversations.py index 0a17ecad28b7..5ade3bd71f82 100644 --- a/src/sentry/api/endpoints/organization_ai_conversations.py +++ b/src/sentry/api/endpoints/organization_ai_conversations.py @@ -1,7 +1,7 @@ import logging import re from collections import defaultdict -from collections.abc import Collection +from collections.abc import Mapping from datetime import datetime from typing import Any, TypedDict @@ -321,9 +321,11 @@ def _get_conversations_data(self, snuba_params, conversation_ids: list[str]) -> # Process results with start_span(op="ai_conversations.process", name="Process query results"): conversations_map = self._build_conversations_from_aggregations(results["aggregations"]) - self._apply_enrichment(conversations_map, results["enrichment"]) + project_ids_by_conversation = self._apply_enrichment( + conversations_map, results["enrichment"] + ) self._apply_first_last_io(conversations_map, results["first_last_io"]) - self._apply_titles(conversations_map, snuba_params.project_ids) + self._apply_titles(conversations_map, project_ids_by_conversation) return [ conversations_map[conv_id] @@ -466,8 +468,8 @@ def _build_conversations_from_aggregations( def _apply_enrichment( self, conversations_map: dict[str, dict[str, Any]], enrichment_data: EAPResponse - ) -> None: - """Apply enrichment data from span rows onto each conversation.""" + ) -> dict[str, set[int]]: + """Apply enrichment data, returning the project ids each conversation spans.""" with start_span( op="ai_conversations.apply_enrichment", name="Apply enrichment data", @@ -479,6 +481,7 @@ def _apply_enrichment( traces_by_conversation: dict[str, set[str]] = defaultdict(set) tool_names_by_conversation: dict[str, set[str]] = defaultdict(set) tool_errors_by_conversation: dict[str, int] = defaultdict(int) + project_ids_by_conversation: dict[str, set[int]] = defaultdict(set) # Rows are sorted by timestamp, so the first occurrence per conversation # is the earliest span. Track the first span's user and project. user_by_conversation: dict[str, UserResponse] = {} @@ -490,8 +493,10 @@ def _apply_enrichment( continue project_id = row.get("project.id") - if isinstance(project_id, int) and conv_id not in first_project_by_conversation: - first_project_by_conversation[conv_id] = project_id + if isinstance(project_id, int): + project_ids_by_conversation[conv_id].add(project_id) + if conv_id not in first_project_by_conversation: + first_project_by_conversation[conv_id] = project_id trace_id = row.get("trace", "") if trace_id: @@ -531,6 +536,8 @@ def _apply_enrichment( conversation["toolErrors"] = tool_errors_by_conversation.get(conv_id, 0) conversation["projectId"] = first_project_by_conversation.get(conv_id) + return project_ids_by_conversation + def _apply_first_last_io( self, conversations_map: dict[str, dict[str, Any]], first_last_io_data: EAPResponse ) -> None: @@ -572,18 +579,23 @@ def _apply_first_last_io( def _apply_titles( self, conversations_map: dict[str, dict[str, Any]], - project_ids: Collection[int], + project_ids_by_conversation: Mapping[str, set[int]], ) -> None: """Set each conversation's `title` from storage when present. On lookup failure, log and leave titles unset so the list response still succeeds. """ + pairs = [ + (conv_id, project_id) + for conv_id in conversations_map + for project_id in project_ids_by_conversation.get(conv_id, ()) + ] try: - titles = fetch_conversation_titles(conversations_map.keys(), project_ids) + titles = fetch_conversation_titles(pairs) except Exception: logger.exception( "Failed to resolve titles for AI conversations", - extra={"project_ids": sorted(project_ids)}, + extra={"project_ids": sorted({project_id for _, project_id in pairs})}, ) return diff --git a/tests/sentry/ai_monitoring/test_utils.py b/tests/sentry/ai_monitoring/test_utils.py index 5e3ad61b9cd5..92b2ba3acda3 100644 --- a/tests/sentry/ai_monitoring/test_utils.py +++ b/tests/sentry/ai_monitoring/test_utils.py @@ -5,18 +5,17 @@ class FetchConversationTitlesTest(TestCase): - def test_returns_empty_without_ids_or_projects(self) -> None: - assert fetch_conversation_titles([], [self.project.id]) == {} - assert fetch_conversation_titles(["conv-1"], []) == {} + def test_returns_empty_for_no_pairs(self) -> None: + assert fetch_conversation_titles([]) == {} - def test_returns_title_for_requested_conversation(self) -> None: + def test_returns_title_for_requested_pair(self) -> None: self.create_ai_conversation_metadata( project=self.project, conversation_id="conv-1", title="Reset my password", ) - titles = fetch_conversation_titles(["conv-1"], [self.project.id]) + titles = fetch_conversation_titles([("conv-1", self.project.id)]) assert titles == {"conv-1": "Reset my password"} @@ -27,7 +26,7 @@ def test_skips_untitled_rows(self) -> None: title=None, ) - assert fetch_conversation_titles(["conv-1"], [self.project.id]) == {} + assert fetch_conversation_titles([("conv-1", self.project.id)]) == {} def test_skips_unknown_conversations(self) -> None: self.create_ai_conversation_metadata( @@ -36,17 +35,37 @@ def test_skips_unknown_conversations(self) -> None: title="Reset my password", ) - assert fetch_conversation_titles(["conv-2"], [self.project.id]) == {} + assert fetch_conversation_titles([("conv-2", self.project.id)]) == {} - def test_ignores_projects_outside_scope(self) -> None: + def test_does_not_return_pairs_that_were_not_requested(self) -> None: + """A row matching the queried hashes and projects, but not as a requested pair.""" other_project = self.create_project(organization=self.organization) + + self.create_ai_conversation_metadata( + project=self.project, + conversation_id="conv-1", + title="Owned by project one", + ) self.create_ai_conversation_metadata( project=other_project, conversation_id="conv-1", title="Owned by project two", ) + self.create_ai_conversation_metadata( + project=other_project, + conversation_id="conv-2", + title="Second conversation", + ) + + # conv-1 is only asked about for self.project, conv-2 only for other_project. + titles = fetch_conversation_titles( + [("conv-1", self.project.id), ("conv-2", other_project.id)] + ) - assert fetch_conversation_titles(["conv-1"], [self.project.id]) == {} + assert titles == { + "conv-1": "Owned by project one", + "conv-2": "Second conversation", + } def test_earliest_source_timestamp_wins(self) -> None: other_project = self.create_project(organization=self.organization) @@ -63,7 +82,9 @@ def test_earliest_source_timestamp_wins(self) -> None: title_source_timestamp=datetime(2024, 5, 1, 11, 0, tzinfo=UTC), ) - titles = fetch_conversation_titles(["conv-1"], [self.project.id, other_project.id]) + titles = fetch_conversation_titles( + [("conv-1", self.project.id), ("conv-1", other_project.id)] + ) assert titles == {"conv-1": "Start of the conversation"} @@ -82,7 +103,9 @@ def test_null_source_timestamp_loses(self) -> None: title_source_timestamp=datetime(2024, 5, 1, 11, 0, tzinfo=UTC), ) - titles = fetch_conversation_titles(["conv-1"], [self.project.id, other_project.id]) + titles = fetch_conversation_titles( + [("conv-1", self.project.id), ("conv-1", other_project.id)] + ) assert titles == {"conv-1": "Start of the conversation"} @@ -109,28 +132,30 @@ def test_ties_break_on_project_id(self) -> None: title_source_timestamp=source_timestamp, ) - titles = fetch_conversation_titles(["conv-1"], [lower_project.id, higher_project.id]) + titles = fetch_conversation_titles( + [("conv-1", lower_project.id), ("conv-1", higher_project.id)] + ) assert titles == {"conv-1": "Lower project id"} - def test_multiple_conversations(self) -> None: + def test_unrequested_earlier_title_does_not_win(self) -> None: other_project = self.create_project(organization=self.organization) self.create_ai_conversation_metadata( project=self.project, conversation_id="conv-1", - title="First", + title="Requested project title", + title_source_timestamp=datetime(2024, 5, 1, 12, 0, tzinfo=UTC), ) self.create_ai_conversation_metadata( project=other_project, - conversation_id="conv-2", - title="Second", + conversation_id="conv-1", + title="Earlier but unrequested", + title_source_timestamp=datetime(2024, 5, 1, 11, 0, tzinfo=UTC), ) - titles = fetch_conversation_titles( - ["conv-1", "conv-2"], [self.project.id, other_project.id] - ) + titles = fetch_conversation_titles([("conv-1", self.project.id)]) - assert titles == {"conv-1": "First", "conv-2": "Second"} + assert titles == {"conv-1": "Requested project title"} class FetchConversationTitleTest(TestCase):