📝 Add docstrings to providenz/conversation-title

Docstrings generation was requested by @providenz.

* https://github.com/suitenumerique/conversations/pull/216#issuecomment-3699989398

The following files were modified:

* `src/backend/chat/clients/pydantic_ai.py`
* `src/backend/chat/serializers.py`
* `src/backend/chat/tests/views/chat/conversations/conftest.py`
* `src/backend/chat/tests/views/chat/conversations/test_conversation_with_history.py`
* `src/frontend/apps/conversations/src/features/chat/api/useChat.tsx`
This commit is contained in:
coderabbitai[bot]
2025-12-30 20:33:07 +00:00
committed by GitHub
parent b483e9c200
commit cc611d35c8
5 changed files with 131 additions and 27 deletions
+35 -13
View File
@@ -357,7 +357,16 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
messages: List[UIMessage],
force_web_search: bool = False,
) -> events_v4.Event | events_v5.Event:
"""Run the Pydantic AI agent and stream events."""
"""
Drive the agent for the provided user message, stream Vercel-AI-SDK event parts representing model and tool activity, and persist the final conversation state.
Parameters:
messages (List[UIMessage]): UI messages for the conversation; the last message must be from the user.
force_web_search (bool): If true, require the agent to invoke the configured web search tool before answering (ignored if the feature or tool is unavailable).
Returns:
events_v4.Event | events_v5.Event: Streamed event parts such as `TextPart`, `ToolCallPart`/`ToolCallStreamingStartPart`/`ToolCallDeltaPart`, `ToolResultPart`, `ReasoningPart`, `SourcePart`, `DataPart`, `StartStepPart`, and `FinishMessagePart` that drive frontend updates.
"""
if messages[-1].role != "user":
return
@@ -777,16 +786,22 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
generated_title: str | None = None,
): # pylint: disable=too-many-arguments
"""
Save everything related to the conversation.
Things to improve here:
- The way we need to add the UI sources to the final output message.
Args:
final_output (List[ModelRequest | ModelMessage]): The final output from the agent.
usage (Dict[str, int]): The token usage statistics.
user_initial_prompt_str (str | None): The initial user prompt string, if any.
ui_sources (List[SourceUIPart]): Optional UI sources to include in the conversation.
Merge the agent's final outputs into the conversation and persist updated conversation state.
Parameters:
final_output (List[ModelRequest | ModelMessage]): Sequence of model requests and responses produced by the agent run; these will be merged into a single request and a single response before saving.
usage (Dict[str, int]): Token usage statistics to store on the conversation (e.g., promptTokens, completionTokens).
final_output_from_tool (str | None): Optional text produced by a tool that should be appended to the final model response.
ui_sources (List[SourceUIPart], optional): Optional UI-visible source parts to attach to the final response message.
model_response_message_id (str | None, optional): If provided, assign this id to the saved model response UI message; if omitted, a warning will be logged.
image_key_mapping (Dict[str, str], optional): Mapping from original (unsigned) media URLs to presigned/rewritten URLs; applied to image/document references in the merged request parts.
generated_title (str | None, optional): Optional auto-generated conversation title to apply to the conversation.
Behavior:
- Merges multiple model request/response objects into a single ModelRequest and ModelResponse.
- Rewrites image/document URLs in user prompt parts when an image_key_mapping is provided.
- Converts merged model messages to UI messages, appends ui_sources if present, and sets the response message id when supplied.
- Appends the merged request and response messages to the conversation, updates agent usage and pydantic messages, applies a generated title if given, and saves the conversation.
"""
_merged_final_output_request = ModelRequest(
parts=[
@@ -837,7 +852,14 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
self.conversation.save()
async def _generate_title(self) -> str | None:
"""Generate a title for the conversation using LLM based on first messages."""
"""
Create a concise conversation title based on the conversation's first messages.
Uses the summarization agent to produce a short title in the same language as the user's messages. Returns the generated title text trimmed to at most 100 characters, or `None` if generation fails or produces no text.
Returns:
str | None: The generated title (trimmed to 100 characters), or `None` when no title is available.
"""
# Build context from the first messages
context = "\n".join(
@@ -864,4 +886,4 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
logger.warning(
"Failed to generate title for conversation %s: %s", self.conversation.pk, exc
)
return None
return None
+9 -1
View File
@@ -30,6 +30,14 @@ class ChatConversationSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
# If title is being changed, mark it as user-set
"""
Update the ChatConversation instance and record when the title is changed by the user.
If `validated_data` contains a `title` different from the instance's current title, sets `title_set_by_user_at` to the current time.
Returns:
The updated ChatConversation instance.
"""
if "title" in validated_data and validated_data["title"] != instance.title:
instance.title_set_by_user_at = timezone.now()
return super().update(instance, validated_data)
@@ -205,4 +213,4 @@ class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
f"File size exceeds the maximum limit of {max_size:d} MB."
)
return size
return size
@@ -11,7 +11,17 @@ from freezegun import freeze_time
def build_openai_stream():
"""Build open ai stream"""
"""
Constructs a string that simulates an OpenAI streaming response payload.
The returned string contains three OpenAI-style `data:` blocks: a first chunk with content "Hello",
a second chunk with content " there" and a `finish_reason` of "stop" (including a `usage` object),
and a final `data: [DONE]` marker. Timestamp fields are generated from timezone.now() converted to
naive timestamps.
Returns:
A string containing concatenated `data:` lines representing streaming chunks and a final `[DONE]` marker.
"""
return (
"data: "
+ json.dumps(
@@ -65,6 +75,12 @@ def fixture_mock_openai_stream():
openai_stream = build_openai_stream()
async def mock_stream():
"""
Yield each line of the prepared OpenAI-style streaming payload as encoded bytes.
Yields:
AsyncGenerator[bytes, None]: Sequential byte chunks for each line in the constructed stream, preserving original line endings.
"""
for line in openai_stream.splitlines(keepends=True):
yield line.encode()
@@ -79,23 +95,42 @@ def fixture_mock_openai_stream():
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_stream_with_title_generation():
"""
Fixture to mock the OpenAI stream response.
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
Mock pytest fixture that intercepts POST requests to the external chat completions endpoint and returns either a streaming chat response or a non-streaming title-generation response depending on the incoming request.
When the request JSON has "stream" set to True, the fixture returns an HTTP streaming response that imitates OpenAI's chat streaming payload; otherwise it returns a non-streaming JSON response containing a generated title and usage metadata.
Returns:
respx.Route: A configured respx route that intercepts POST requests to
"https://www.external-ai-service.com/chat/completions" and replies based on the request body.
"""
def create_stream_response():
"""Create a fresh streaming response for each call."""
"""
Create an HTTP response whose body streams encoded lines of an OpenAI-style streaming payload.
Returns:
httpx.Response: HTTP 200 response with a streaming body that yields encoded bytes for each line of the streaming payload.
"""
openai_stream = build_openai_stream()
async def mock_stream():
"""
Yield encoded byte chunks for each line of the OpenAI stream.
Each yielded value is a bytes object containing one line (including its line ending) from the prebuilt OpenAI streaming payload, suitable for use as an HTTP streaming response body.
"""
for line in openai_stream.splitlines(keepends=True):
yield line.encode()
return httpx.Response(200, stream=mock_stream())
def create_non_stream_response():
"""Create a non-streaming response for title generation."""
"""
Create a non-streaming OpenAI-like chat completion response containing a generated title.
Returns:
httpx.Response: HTTP 200 response whose JSON payload represents a chat completion with a single assistant message containing the generated title and accompanying metadata (id, model, timestamps, choices, and usage).
"""
return httpx.Response(
200,
json={
@@ -118,7 +153,15 @@ def fixture_mock_openai_stream_with_title_generation():
)
def handle_request(request):
"""Route to streaming or non-streaming response based on request."""
"""
Selects a streaming or non-streaming HTTP response based on the request JSON `stream` flag.
Parameters:
request (httpx.Request): Incoming request whose JSON body is inspected for the `stream` boolean flag.
Returns:
httpx.Response: A response that streams the OpenAI-style event lines if `stream` is True, otherwise a non-streaming JSON response.
"""
body = json.loads(request.content)
if body.get("stream", False):
return create_stream_response()
@@ -134,7 +177,14 @@ def fixture_mock_openai_stream_with_title_generation():
@pytest.fixture(name="mock_openai_no_stream")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_no_stream():
"""Fixture to mock the OpenAI response."""
"""
Create a respx route that returns a fixed, non-streaming OpenAI chat completion response.
The mocked response is an HTTP 200 JSON payload representing a completed assistant message (explaining Rayleigh scattering) with associated metadata and usage details.
Returns:
respx.Route: The configured respx route intercepting POST requests to https://www.external-ai-service.com/chat/completions.
"""
route = respx.post("https://www.external-ai-service.com/chat/completions").mock(
return_value=httpx.Response(
@@ -448,4 +498,4 @@ def fixture_mock_openai_stream_tool():
]
)
return route
return route
@@ -29,7 +29,18 @@ pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture(autouse=True)
def ai_settings(settings):
"""Fixture to set AI service URLs for testing."""
"""
Configure AI-related settings for tests on the provided settings object.
Sets test values for AI service base URL, API key, model, agent instructions, and sets
AUTO_TITLE_AFTER_USER_MESSAGES to 999 to disable automatic title generation during tests.
Parameters:
settings (object): Django settings-like object to be mutated for test configuration.
Returns:
object: The same settings object with AI-related test configuration applied.
"""
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
settings.AI_API_KEY = "test-api-key"
settings.AI_MODEL = "test-model"
@@ -1750,4 +1761,4 @@ def test_post_conversation_does_not_generate_title_before_threshold(
assert conversation.title == "initial title"
assert not conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.call_count == 1
assert mock_openai_stream_with_title_generation.call_count == 1
@@ -44,7 +44,12 @@ interface ConversationMetadataEvent {
conversationId: string;
title: string;
}
// Type guard to check if an item is a ConversationMetadataEvent
/**
* Type guard that determines whether a value is a ConversationMetadataEvent.
*
* @param item - Value to test
* @returns `true` if `item` is a ConversationMetadataEvent, `false` otherwise.
*/
function isConversationMetadataEvent(
item: unknown,
): item is ConversationMetadataEvent {
@@ -56,6 +61,14 @@ function isConversationMetadataEvent(
);
}
/**
* Hook that provides chat functionality with a custom fetch adapter and automatic conversation-list cache invalidation.
*
* The hook invokes the underlying AI chat implementation with `maxSteps` set to 3 and a fetch wrapper that appends UI-driven query parameters; when the chat stream emits a `conversation_metadata` event the hook invalidates the conversation list cache (KEY_LIST_CONVERSATION).
*
* @param options - Chat configuration options (note: `maxSteps` is overridden to 3 and the `fetch` implementation is replaced)
* @returns The chat hook result object containing `data`, status flags, and control methods for interacting with the chat stream.
*/
export function useChat(options: Omit<UseChatOptions, 'fetch'>) {
const queryClient = useQueryClient();
@@ -77,4 +90,4 @@ export function useChat(options: Omit<UseChatOptions, 'fetch'>) {
}
}, [result.data, queryClient]);
return result;
}
}