Repository: RCAccelerator/chatbot
Pull request #43: Catch exceptions related to file attachments
Author: login: lpiwowar
Created: 2025-04-04T09:23:58Z | Merged: 2025-04-07T17:50:33Z

- Make sure that we let the user know when there is an error while processing the input file and we log the exception.

- Remove model_settings variable. The settings variable is a dictionary which contains all the required values.

- Refactor the code of `handle_user_message()` to prevent `too-many-locals` pylint error.


Diff:
diff --git a/src/chat.py b/src/chat.py
index 57914e6..241f991 100644
--- a/src/chat.py
+++ b/src/chat.py
@@ -2,7 +2,7 @@
 import chainlit as cl
 import httpx
 
-from generation import process_message_and_get_response
+from generation import get_response, ModelSettings
 from embeddings import search_similar_content, get_num_tokens
 from config import config, SUGGESTED_MINIMUM_SIMILARITY_THRESHOLD
 
@@ -129,6 +129,35 @@ async def check_message_length(message_content: str) -> tuple[bool, str]:
     return True, ""
 
 
+async def print_debug_content(
+        settings: dict, search_results: list[dict]) -> None:
+    """Print debug content if user requested it.
+
+    Args:
+        settings: The settings user provided through the UI.
+        search_results: The results we obtained from the vector database.
+    """
+    # Initialize debug_content with all settings
+    debug_content = ""
+    if settings:
+        debug_content = "**Current Settings:**\n"
+        for key, value in settings.items():
+            debug_content += f"- {key}: {value}\n"
+    debug_content += "\n"
+
+    # Display vector DB debug information if debug mode is enabled
+    if search_results:
+        debug_content += "**Vector DB Search Results:**\n"
+        for i, result in enumerate(search_results[:10], 1):
+            debug_content += (
+                f"**Result {i}**\n"
+                f"- Score: {result.get('score', 0)}\n"
+                f"- URL: {result.get('url', 'N/A')}\n"
+                f"- Preview: {result.get('text', 'N/A')[:500]}...\n\n"
+            )
+    await cl.Message(content=debug_content).send()
+
+
 async def handle_user_message(message: cl.Message, debug_mode=False):
     """
     Main handler for user messages.
@@ -138,25 +167,17 @@ async def handle_user_message(message: cl.Message, debug_mode=False):
         debug_mode: Whether to show debug information
     """
     settings = cl.user_session.get("settings")
-    model_settings = {
-        "model": settings["model"],
-        "temperature": settings["temperature"],
-        "max_tokens": settings["max_tokens"],
-        "stream": settings["stream"]
-    }
-
     resp = cl.Message(content="")
 
-    # Initialize debug_content with all settings
-    debug_content = "**Current Settings:**\n"
-    if settings:
-        for key, value in settings.items():
-            debug_content += f"- {key}: {value}\n"
-    debug_content += "\n"
-
-    if message.elements and message.elements[0].path:
-        with open(message.elements[0].path, 'r', encoding='utf-8') as file:
-            message.content += file.read()
+    try:
+        if message.elements and message.elements[0].path:
+            with open(message.elements[0].path, 'r', encoding='utf-8') as file:
+                message.content += file.read()
+    except OSError as e:
+        cl.logger.error(e)
+        resp.content = "An error occurred while processing your file."
+        await resp.send()
+        return
 
     # Check message length
     is_valid_length, error_message = await check_message_length(
@@ -170,24 +191,22 @@ async def handle_user_message(message: cl.Message, debug_mode=False):
         st = get_similarity_threshold()
         search_results = await perform_search(user_content=message.content,
                                               similarity_threshold=st)
-
-        # Display vector DB debug information if debug mode is enabled
         if debug_mode:
-            if search_results:
-                debug_content += "**Vector DB Search Results:**\n"
-                for i, result in enumerate(search_results[:10], 1):
-                    debug_content += (
-                        f"**Result {i}**\n"
-                        f"- Score: {result.get('score', 0)}\n"
-                        f"- URL: {result.get('url', 'N/A')}\n"
-                        f"- Preview: {result.get('text', 'N/A')[:500]}...\n\n"
-                    )
-            await cl.Message(content=debug_content).send()
+            await print_debug_content(settings, search_results)
 
         message.content += build_prompt(search_results)
 
+    model_settings: ModelSettings = {
+        "model": settings["model"],
+        "max_tokens": settings["max_tokens"],
+        "temperature": settings["temperature"],
+    }
+
     # Process user message and get AI response
-    await process_message_and_get_response(message, resp, model_settings)
+    await get_response(
+        message, resp, model_settings,
+        stream_response=settings.get("stream", True)
+    )
 
     # Extend response with searched jira urls
     append_searched_urls(search_results, resp)
diff --git a/src/generation.py b/src/generation.py
index 02e8938..76b0915 100644
--- a/src/generation.py
+++ b/src/generation.py
@@ -1,6 +1,9 @@
 """Text generation with large language models."""
+from typing import TypedDict
+
 import chainlit as cl
 from openai import AsyncOpenAI, OpenAIError
+from openai.types.chat import ChatCompletionMessageParam
 
 from config import config
 
@@ -12,58 +15,57 @@
 )
 
 
-async def generate_response(user_message, model_settings, stream=True):
+class ModelSettings(TypedDict):
     """
-    Generate a response from the LLM based on the user message.
-
-    Args:
-        user_message: The user's input message
-        model_settings: Dictionary containing model configuration parameters
-        stream: Whether to stream the response or return it all at once
+    A dictionary type that defines the settings for a model.
 
-    Returns:
-        Generated response text or stream
+    Attributes:
+        model: The name of the model.
+        temperature: The temperature to use for generation.
+        max_tokens: The maximum number of tokens in the output.
     """
-    message_history = [{"role": "system", "content": config.system_prompt}]
-    message_history.append({"role": "user", "content": user_message})
+    model: str
+    temperature: float
+    max_tokens: int
 
-    try:
-        if stream:
-            return await gen_llm.chat.completions.create(
-                messages=message_history, **model_settings
-            )
-        response = await gen_llm.chat.completions.create(
-            messages=message_history, **model_settings
-        )
-        return response.choices[0].message.content
-    except OpenAIError as e:
-        cl.logger.error("Error generating response: %s", str(e))
-        return "I'm sorry, I encountered an error while generating a response."
 
+async def get_response(user_message: cl.Message, response_msg: cl.Message,
+                       model_settings: ModelSettings,
+                       stream_response: bool = True) -> None:
+    """Process the user's message and generate a response using the LLM.
 
-async def process_message_and_get_response(user_message, response_msg,
-                                           model_settings):
-    """
-    Process the user message and generate an AI response.
+    This function constructs the prompt from the LLM by compbining the system
+    prompt with the user's input. It then sends the prepared message to the LLM
+    for response generation.
 
     Args:
-        user_message: The user's input message object
-        response_msg: The response message object to populate
-        model_settings: Dictionary containing model configuration parameters
+        user_message: The user's input message object.
+        response_msg: The message object to populate with the LLM's
+            generated response or an error message if something goes wrong.
+        model_settings: A dictionary containing LLM configuration.
+        stream_response: Indicates whether we want to stream the response or
+            get the process in a single chunk.
     """
+    message_history: list[ChatCompletionMessageParam] = [
+        {"role": "system", "content": config.system_prompt},
+        {"role": "user", "content": user_message.content},
+    ]
+
     try:
-        if model_settings.get('stream', True):
-            async for stream_resp in await generate_response(
-                user_message.content, model_settings, stream=True
+        if stream_response:
+            async for stream_resp in await gen_llm.chat.completions.create(
+                messages=message_history, stream=stream_response,
+                **model_settings
             ):
                 if stream_resp.choices and len(stream_resp.choices) > 0:
                     if token := stream_resp.choices[0].delta.content or "":
                         await response_msg.stream_token(token)
         else:
-            content = await generate_response(
-                user_message.content, model_settings, stream=False
+            response = await gen_llm.chat.completions.create(
+                messages=message_history, stream=stream_response,
+                **model_settings
             )
-            response_msg.content = content
+            response_msg.content = response.choices[0].message.content or ""
     except OpenAIError as e:
         cl.logger.error("Error in process_message_and_get_response: %s",
                         str(e))


Reviews:
user: login: EmilienM
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T12:12:55Z
user: login: lpiwowar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T15:53:46Z
user: login: lpiwowar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T10:21:19Z
user: login: jpodivin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T11:56:44Z
user: login: lpiwowar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T12:02:00Z
user: login: EmilienM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T17:50:22Z

Review comments:
diff_hunk: @@ -187,7 +186,7 @@ async def handle_user_message(message: cl.Message, debug_mode=False):
         message.content += build_prompt(search_results)
 
     # Process user message and get AI response
-    await process_message_and_get_response(message, resp, model_settings)
+    await process_message_and_get_response(message, resp, settings)
path: src/chat.py
user: login: EmilienM
body: please update `process_message_and_get_response` as well, to rename `model_settings`.
created_at: 2025-04-04T12:12:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,7 +186,7 @@ async def handle_user_message(message: cl.Message, debug_mode=False):
         message.content += build_prompt(search_results)
 
     # Process user message and get AI response
-    await process_message_and_get_response(message, resp, model_settings)
+    await process_message_and_get_response(message, resp, settings)
path: src/chat.py
user: login: lpiwowar
body: Will do first thing on Monday!
created_at: 2025-04-04T15:53:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,7 +186,7 @@ async def handle_user_message(message: cl.Message, debug_mode=False):
         message.content += build_prompt(search_results)
 
     # Process user message and get AI response
-    await process_message_and_get_response(message, resp, model_settings)
+    await process_message_and_get_response(message, resp, settings)
path: src/chat.py
user: login: lpiwowar
body: I wanted to move the whole dictionary into the process_message_and_get_response because of a pylint issue: `too many local variables`. I decided to resolve this differently because I think:

- the debug printing can be moved to a separate function and
- the `process_message_and_get_response` (now named `get_response`) should be receiving only the parameters it is going to work with.

I have to test that the changes did not break anything. Will do after lunch. Please, let me know if what I'm doing does not make sense.
created_at: 2025-04-07T10:21:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,7 +186,7 @@ async def handle_user_message(message: cl.Message, debug_mode=False):
         message.content += build_prompt(search_results)
 
     # Process user message and get AI response
-    await process_message_and_get_response(message, resp, model_settings)
+    await process_message_and_get_response(message, resp, settings)
path: src/chat.py
user: login: jpodivin
body: Note that pylint is not your master. Sometimes it makes sense to go against it, and just override recommendations with pragma. 
created_at: 2025-04-07T11:56:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,7 +186,7 @@ async def handle_user_message(message: cl.Message, debug_mode=False):
         message.content += build_prompt(search_results)
 
     # Process user message and get AI response
-    await process_message_and_get_response(message, resp, model_settings)
+    await process_message_and_get_response(message, resp, settings)
path: src/chat.py
user: login: lpiwowar
body: Yes, I know. But I think in this case it actually made sense to obey it. The `handle_user_message()` function IS getting chunkier.
created_at: 2025-04-07T12:02:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: lpiwowar
created_at: 2025-04-04T09:25:08Z
body: Context: https://github.com/RCAccelerator/chatbot/pull/37#discussion_r2028048366
author_association: CONTRIBUTOR
user: login: lpiwowar
created_at: 2025-04-07T11:15:34Z
body: ## Proof of testing

I tested the following scenarios on my env:
 - debug option works (enabled / disabled)
 - stream works (enabled / disabled)
 - file attachments (too long / normal length)

Here are the screenshots to prove the testing:

### Check debug options still work (enabled)

![image](https://github.com/user-attachments/assets/bf4de52f-1234-47e3-a742-11b8fe2c6239)

### Check debug options still work (disabled)

![image](https://github.com/user-attachments/assets/dbec17c8-42bd-4db2-9783-1b4f332993fe)

### Check that streaming works (enabled)

![image](https://github.com/user-attachments/assets/a80f0f10-7d68-40ec-9a03-ba0b53609847)

### Check that streaming works (disabled)

![image](https://github.com/user-attachments/assets/2185aad7-cc4a-4bbb-88f8-4357d83d4cf7)

### Attaching of logs (too long / normal length)

![image](https://github.com/user-attachments/assets/1e69d9c2-1cc1-4242-8cee-6da11e04f687)

author_association: CONTRIBUTOR
user: login: EmilienM
created_at: 2025-04-07T11:54:09Z
body: Please rebase on main
author_association: CONTRIBUTOR