📝 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:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user