Skip to content

Commit 9f4acf0

Browse files
authored
Merge pull request #1263 from maysunfaisal/fix-ref-docs-1
[RHIDP-12426] Fix Regression on /v2/conversations for referenced_documents caching
2 parents ad686e4 + 0fcf711 commit 9f4acf0

6 files changed

Lines changed: 241 additions & 9 deletions

File tree

src/app/endpoints/conversations_v2.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ async def get_conversations_list_endpoint_handler(
107107
return ConversationsListResponseV2(conversations=conversations)
108108

109109

110-
@router.get("/conversations/{conversation_id}", responses=conversation_get_responses)
110+
@router.get(
111+
"/conversations/{conversation_id}",
112+
responses=conversation_get_responses,
113+
)
111114
@authorize(Action.GET_CONVERSATION)
112115
async def get_conversation_endpoint_handler(
113116
request: Request, # pylint: disable=unused-argument
@@ -257,8 +260,12 @@ def build_conversation_turn_from_cache_entry(entry: CacheEntry) -> ConversationT
257260
"""
258261
# Create Message objects for user and assistant
259262
messages = [
260-
Message(content=entry.query, type="user"),
261-
Message(content=entry.response, type="assistant"),
263+
Message(content=entry.query, type="user", referenced_documents=None),
264+
Message(
265+
content=entry.response,
266+
type="assistant",
267+
referenced_documents=entry.referenced_documents or None,
268+
),
262269
]
263270

264271
# Extract tool calls and results (default to empty lists if None)

src/models/responses.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,7 @@ class Message(BaseModel):
866866
Attributes:
867867
content: The message content.
868868
type: The type of message.
869+
referenced_documents: Optional list of documents referenced in an assistant response.
869870
"""
870871

871872
content: str = Field(
@@ -878,6 +879,10 @@ class Message(BaseModel):
878879
description="The type of message",
879880
examples=["user", "assistant", "system", "developer"],
880881
)
882+
referenced_documents: Optional[list[ReferencedDocument]] = Field(
883+
None,
884+
description="List of documents referenced in the response (assistant messages only)",
885+
)
881886

882887

883888
class ConversationTurn(BaseModel):

src/utils/conversations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def _parse_message_item(item: MessageOutput) -> Message:
7171
"""
7272
content_text = _extract_text_from_content(item.content)
7373
message_type = item.role
74-
return Message(content=content_text, type=message_type)
74+
return Message(content=content_text, type=message_type, referenced_documents=None)
7575

7676

7777
def _build_tool_call_summary_from_item( # pylint: disable=too-many-return-statements

tests/unit/app/endpoints/test_conversations.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ async def test_build_conversation_turns_from_items_with_model_dump(
436436
result = build_conversation_turns_from_items(
437437
mock_items, mock_db_turns, conversation_start_time
438438
)
439-
actual_history = [turn.model_dump() for turn in result]
439+
actual_history = [turn.model_dump(exclude_none=True) for turn in result]
440440
assert actual_history == expected_chat_history
441441

442442
@pytest.mark.asyncio
@@ -745,7 +745,9 @@ async def test_successful_conversation_retrieval(
745745

746746
assert isinstance(response, ConversationResponse)
747747
assert response.conversation_id == VALID_CONVERSATION_ID
748-
actual_history = [turn.model_dump() for turn in response.chat_history]
748+
actual_history = [
749+
turn.model_dump(exclude_none=True) for turn in response.chat_history
750+
]
749751
assert actual_history == expected_chat_history
750752

751753
@pytest.mark.asyncio

tests/unit/app/endpoints/test_conversations_v2.py

Lines changed: 217 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
ConversationUpdateResponse,
2727
)
2828
from tests.unit.utils.auth_helpers import mock_authorization_resolvers
29-
from utils.types import ToolCallSummary, ToolResultSummary
29+
from utils.types import ReferencedDocument, ToolCallSummary, ToolResultSummary
3030

3131
MOCK_AUTH = ("mock_user_id", "mock_username", False, "mock_token")
3232
VALID_CONVERSATION_ID = "123e4567-e89b-12d3-a456-426614174000"
@@ -95,6 +95,133 @@ def test_build_turn_with_tool_calls(self) -> None:
9595
assert len(turn.tool_results) == 1
9696
assert turn.tool_results[0].status == "success"
9797

98+
def test_build_turn_with_referenced_documents(self) -> None:
99+
"""Test that referenced_documents from cache are included in the assistant message."""
100+
ref_docs = [
101+
ReferencedDocument(
102+
doc_url="https://docs.example.com/page1",
103+
doc_title="Page 1",
104+
source="vs_abc123",
105+
),
106+
ReferencedDocument(
107+
doc_url="https://docs.example.com/page2",
108+
doc_title="Page 2",
109+
source="vs_abc123",
110+
),
111+
]
112+
entry = CacheEntry(
113+
query="What is RHDH?",
114+
response="RHDH is a developer hub.",
115+
provider="vllm",
116+
model="llama-3",
117+
started_at="2024-01-01T00:00:00Z",
118+
completed_at="2024-01-01T00:00:05Z",
119+
referenced_documents=ref_docs,
120+
)
121+
122+
turn = build_conversation_turn_from_cache_entry(entry)
123+
124+
assert len(turn.messages) == 2
125+
user_msg = turn.messages[0]
126+
assistant_msg = turn.messages[1]
127+
128+
assert user_msg.type == "user"
129+
assert user_msg.referenced_documents is None
130+
131+
assert assistant_msg.type == "assistant"
132+
assert assistant_msg.referenced_documents is not None
133+
assert len(assistant_msg.referenced_documents) == 2
134+
assert (
135+
str(assistant_msg.referenced_documents[0].doc_url)
136+
== "https://docs.example.com/page1"
137+
)
138+
assert assistant_msg.referenced_documents[0].doc_title == "Page 1"
139+
assert (
140+
str(assistant_msg.referenced_documents[1].doc_url)
141+
== "https://docs.example.com/page2"
142+
)
143+
assert assistant_msg.referenced_documents[1].doc_title == "Page 2"
144+
145+
def test_build_turn_without_referenced_documents(self) -> None:
146+
"""Test that assistant message has no referenced_documents when cache entry has none."""
147+
entry = CacheEntry(
148+
query="Hello",
149+
response="Hi there!",
150+
provider="openai",
151+
model="gpt-4",
152+
started_at="2024-01-01T00:00:00Z",
153+
completed_at="2024-01-01T00:00:05Z",
154+
)
155+
156+
turn = build_conversation_turn_from_cache_entry(entry)
157+
158+
assert turn.messages[1].type == "assistant"
159+
assert turn.messages[1].referenced_documents is None
160+
161+
def test_build_turn_with_empty_referenced_documents(self) -> None:
162+
"""Test assistant message has no referenced_documents when cache has empty list."""
163+
entry = CacheEntry(
164+
query="Hello",
165+
response="Hi there!",
166+
provider="openai",
167+
model="gpt-4",
168+
started_at="2024-01-01T00:00:00Z",
169+
completed_at="2024-01-01T00:00:05Z",
170+
referenced_documents=[],
171+
)
172+
173+
turn = build_conversation_turn_from_cache_entry(entry)
174+
175+
assert turn.messages[1].type == "assistant"
176+
assert turn.messages[1].referenced_documents is None
177+
178+
def test_build_turn_serialization_excludes_none_referenced_documents(self) -> None:
179+
"""Test that model_dump(exclude_none=True) omits referenced_documents when None."""
180+
entry = CacheEntry(
181+
query="Hello",
182+
response="Hi there!",
183+
provider="openai",
184+
model="gpt-4",
185+
started_at="2024-01-01T00:00:00Z",
186+
completed_at="2024-01-01T00:00:05Z",
187+
)
188+
189+
turn = build_conversation_turn_from_cache_entry(entry)
190+
dumped = turn.model_dump(exclude_none=True)
191+
192+
user_msg_dict = dumped["messages"][0]
193+
assistant_msg_dict = dumped["messages"][1]
194+
assert "referenced_documents" not in user_msg_dict
195+
assert "referenced_documents" not in assistant_msg_dict
196+
197+
def test_build_turn_serialization_includes_referenced_documents(self) -> None:
198+
"""Test that model_dump(exclude_none=True) includes referenced_documents when present."""
199+
ref_docs = [
200+
ReferencedDocument(
201+
doc_url="https://docs.example.com/page1",
202+
doc_title="Page 1",
203+
),
204+
]
205+
entry = CacheEntry(
206+
query="What is RHDH?",
207+
response="RHDH is a developer hub.",
208+
provider="vllm",
209+
model="llama-3",
210+
started_at="2024-01-01T00:00:00Z",
211+
completed_at="2024-01-01T00:00:05Z",
212+
referenced_documents=ref_docs,
213+
)
214+
215+
turn = build_conversation_turn_from_cache_entry(entry)
216+
dumped = turn.model_dump(exclude_none=True)
217+
218+
user_msg_dict = dumped["messages"][0]
219+
assistant_msg_dict = dumped["messages"][1]
220+
assert "referenced_documents" not in user_msg_dict
221+
assert "referenced_documents" in assistant_msg_dict
222+
assert len(assistant_msg_dict["referenced_documents"]) == 1
223+
assert assistant_msg_dict["referenced_documents"][0]["doc_title"] == "Page 1"
224+
98225

99226
@pytest.fixture
100227
def mock_configuration(mocker: MockerFixture) -> MockType:
@@ -428,6 +555,95 @@ async def test_successful_retrieval(
428555
assert len(response.chat_history) == 1
429556
assert response.chat_history[0].messages[0].content == "query"
430557

558+
@pytest.mark.asyncio
559+
async def test_successful_retrieval_includes_referenced_documents(
560+
self, mocker: MockerFixture, mock_configuration: MockType
561+
) -> None:
562+
"""Test that GET conversation includes referenced_documents in assistant messages."""
563+
mock_authorization_resolvers(mocker)
564+
mocker.patch("app.endpoints.conversations_v2.configuration", mock_configuration)
565+
mocker.patch("app.endpoints.conversations_v2.check_suid", return_value=True)
566+
ref_docs = [
567+
ReferencedDocument(
568+
doc_url="https://docs.example.com/intro",
569+
doc_title="Introduction",
570+
source="vs_abc123",
571+
),
572+
ReferencedDocument(
573+
doc_url="https://docs.example.com/guide",
574+
doc_title="User Guide",
575+
source="vs_abc123",
576+
),
577+
]
578+
mock_configuration.conversation_cache.list.return_value = [
579+
mocker.Mock(conversation_id=VALID_CONVERSATION_ID)
580+
]
581+
mock_configuration.conversation_cache.get.return_value = [
582+
CacheEntry(
583+
query="What is RHDH?",
584+
response="RHDH is a developer hub.",
585+
provider="vllm",
586+
model="llama-3",
587+
started_at="2024-01-01T00:00:00Z",
588+
completed_at="2024-01-01T00:00:05Z",
589+
referenced_documents=ref_docs,
590+
)
591+
]
592+
593+
response = await get_conversation_endpoint_handler(
594+
request=mocker.Mock(),
595+
conversation_id=VALID_CONVERSATION_ID,
596+
auth=MOCK_AUTH,
597+
)
598+
599+
assert response is not None
600+
assert len(response.chat_history) == 1
601+
turn = response.chat_history[0]
602+
603+
user_msg = turn.messages[0]
604+
assert user_msg.type == "user"
605+
assert user_msg.referenced_documents is None
606+
607+
assistant_msg = turn.messages[1]
608+
assert assistant_msg.type == "assistant"
609+
assert assistant_msg.referenced_documents is not None
610+
assert len(assistant_msg.referenced_documents) == 2
611+
assert assistant_msg.referenced_documents[0].doc_title == "Introduction"
612+
assert assistant_msg.referenced_documents[1].doc_title == "User Guide"
613+
614+
@pytest.mark.asyncio
615+
async def test_successful_retrieval_without_referenced_documents(
616+
self, mocker: MockerFixture, mock_configuration: MockType
617+
) -> None:
618+
"""Test that GET conversation works when cache entry has no referenced_documents."""
619+
mock_authorization_resolvers(mocker)
620+
mocker.patch("app.endpoints.conversations_v2.configuration", mock_configuration)
621+
mocker.patch("app.endpoints.conversations_v2.check_suid", return_value=True)
622+
mock_configuration.conversation_cache.list.return_value = [
623+
mocker.Mock(conversation_id=VALID_CONVERSATION_ID)
624+
]
625+
mock_configuration.conversation_cache.get.return_value = [
626+
CacheEntry(
627+
query="Hello",
628+
response="Hi there!",
629+
provider="openai",
630+
model="gpt-4",
631+
started_at="2024-01-01T00:00:00Z",
632+
completed_at="2024-01-01T00:00:05Z",
633+
)
634+
]
635+
636+
response = await get_conversation_endpoint_handler(
637+
request=mocker.Mock(),
638+
conversation_id=VALID_CONVERSATION_ID,
639+
auth=MOCK_AUTH,
640+
)
641+
642+
assert response is not None
643+
turn = response.chat_history[0]
644+
assert turn.messages[1].type == "assistant"
645+
assert turn.messages[1].referenced_documents is None
646+
431647
@pytest.mark.asyncio
432648
async def test_with_skip_userid_check(
433649
self, mocker: MockerFixture, mock_configuration: MockType

tests/unit/models/responses/test_successful_responses.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,8 +607,10 @@ def test_constructor(self) -> None:
607607
)
608608
assert isinstance(response, AbstractSuccessfulResponse)
609609
assert response.conversation_id == "123e4567-e89b-12d3-a456-426614174000"
610-
# Convert ConversationTurn objects to dicts for comparison
611-
actual_history = [turn.model_dump() for turn in response.chat_history]
610+
# Convert ConversationTurn objects to dicts for comparison (exclude None for clean output)
611+
actual_history = [
612+
turn.model_dump(exclude_none=True) for turn in response.chat_history
613+
]
612614
assert actual_history == chat_history
613615

614616
def test_empty_chat_history(self) -> None:

0 commit comments

Comments
 (0)