Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions chatbot-core/api/prompts/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from langchain.memory import ConversationBufferMemory
from api.prompts.prompts import SYSTEM_INSTRUCTION, LOG_ANALYSIS_INSTRUCTION


def build_prompt(
user_query: str,
context: str,
Expand Down
13 changes: 9 additions & 4 deletions chatbot-core/api/services/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@
SPLIT_QUERY_PROMPT,
LOG_SUMMARY_PROMPT,
)

from api.tools.sanitizer import sanitize_logs
from api.services.memory import get_session, get_session_async
from api.services.file_service import format_file_context
from api.tools.sanitizer import sanitize_logs
from api.tools.tools import TOOL_REGISTRY
from api.tools.utils import (
get_default_tools_call,
Expand Down Expand Up @@ -69,11 +68,13 @@ def get_chatbot_reply(
ChatResponse: The generated assistant response.
"""
logger.info("New message from session '%s'", session_id)
user_input = sanitize_logs(user_input)
logger.debug("Handling the user query: %s", _sanitize_log_payload(user_input))

memory = get_session(session_id)
if memory is None:
raise RuntimeError(f"Session '{session_id}' not found in the memory store.")
raise RuntimeError(
f"Session '{session_id}' not found in the memory store.")

context = retrieve_context(user_input)
logger.debug("Context retrieved: %s", _sanitize_log_payload(context))
Expand Down Expand Up @@ -140,6 +141,7 @@ def get_chatbot_reply_new_architecture(
ChatResponse: The generated assistant response.
"""
logger.info("New message from session '%s'", session_id)
user_input = sanitize_logs(user_input)
logger.debug("Handling the user query: %s", _sanitize_log_payload(user_input))

memory = get_session(session_id)
Expand Down Expand Up @@ -341,7 +343,8 @@ def _execute_search_tools(tool_calls) -> str:
})

return "\n\n".join(
f"[Result of the search tool {res['tool']}]:\n{res.get('output', '')}".strip()
f"[Result of the search tool {res['tool']}]:\n{res.get('output', '')}".strip(
)
for res in retrieved_results
)

Expand Down Expand Up @@ -497,6 +500,7 @@ async def get_chatbot_reply_stream(
str: Individual tokens from LLM response
"""
logger.info("Streaming message from session '%s'", session_id)
user_input = sanitize_logs(user_input)
logger.debug("Handling user query: %s", _sanitize_log_payload(user_input))

memory = await get_session_async(session_id)
Expand Down Expand Up @@ -548,6 +552,7 @@ def _extract_relevance_score(response: str) -> str:

return relevance_score


def _generate_search_query_from_logs(log_text: str) -> str:
"""
Uses the LLM to extract a concise error signature from the logs
Expand Down
69 changes: 61 additions & 8 deletions chatbot-core/tests/unit/services/test_chat_service.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"""Unit tests for chat service logic."""

import logging
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from api.services.chat_service import generate_answer, get_chatbot_reply, retrieve_context
from api.config.loader import CONFIG
from api.models.schemas import ChatResponse


def test_get_chatbot_reply_success(
mock_get_session,
mock_retrieve_context,
Expand All @@ -27,8 +28,10 @@ def test_get_chatbot_reply_success(

assert isinstance(response, ChatResponse)
assert response.reply == "LLM answers to the query"
mock_chat_memory.add_user_message.assert_called_once_with("Query for the LLM")
mock_chat_memory.add_ai_message.assert_called_once_with("LLM answers to the query")
mock_chat_memory.add_user_message.assert_called_once_with(
"Query for the LLM")
mock_chat_memory.add_ai_message.assert_called_once_with(
"LLM answers to the query")


def test_get_chatbot_reply_session_not_found(mock_get_session):
Expand All @@ -38,7 +41,8 @@ def test_get_chatbot_reply_session_not_found(mock_get_session):
with pytest.raises(RuntimeError) as exc_info:
get_chatbot_reply("missing-session-id", "Query for the LLM")

assert "Session 'missing-session-id' not found in the memory store." in str(exc_info.value)
assert "Session 'missing-session-id' not found in the memory store." in str(
exc_info.value)


def test_get_chatbot_reply_does_not_log_raw_content(
Expand Down Expand Up @@ -148,9 +152,11 @@ def test_retrieve_context_no_documents(mock_get_relevant_documents):

assert result == CONFIG["retrieval"]["empty_context_message"]


def test_retrieve_context_missing_id(mock_get_relevant_documents, caplog):
"""Test retrieve_context skips chunks missing an ID and logs a warning."""
mock_get_relevant_documents.return_value = (get_mock_documents("missing_id"), None)
mock_get_relevant_documents.return_value = (
get_mock_documents("missing_id"), None)
logging.getLogger("API").propagate = True

with caplog.at_level(logging.WARNING):
Expand All @@ -162,7 +168,8 @@ def test_retrieve_context_missing_id(mock_get_relevant_documents, caplog):

def test_retrieve_context_missing_text(mock_get_relevant_documents, caplog):
"""Test retrieve_context skips chunks missing text and logs a warning."""
mock_get_relevant_documents.return_value = (get_mock_documents("missing_text"), None)
mock_get_relevant_documents.return_value = (
get_mock_documents("missing_text"), None)
logging.getLogger("API").propagate = True

with caplog.at_level(logging.WARNING):
Expand Down Expand Up @@ -191,7 +198,6 @@ def test_retrieve_context_with_missing_code(mock_get_relevant_documents, caplog)
assert "More placeholders than code blocks in chunk with ID doc-111" in caplog.text



def get_mock_documents(doc_type: str):
"""Helper function to retrieve the mock documents."""
if doc_type == "with_placeholders":
Expand Down Expand Up @@ -222,7 +228,7 @@ def get_mock_documents(doc_type: str):
"code_blocks": ["print('no text here')"]
}
]
if doc_type== "missing_code":
if doc_type == "missing_code":
return [
{
"id": "doc-111",
Expand All @@ -233,3 +239,50 @@ def get_mock_documents(doc_type: str):
}
]
return []


@patch("api.services.chat_service.get_session")
@patch("api.services.chat_service.retrieve_context")
@patch("api.services.chat_service.build_prompt")
@patch("api.services.chat_service.generate_answer")
def test_get_chatbot_reply_sanitizes_secrets_at_entry(
mock_generate_answer,
mock_build_prompt,
mock_retrieve_context,
mock_get_session
):
"""
Ensures that user input containing secrets is sanitized immediately at the
producer level before being passed to retrieval, prompt building, or memory.
"""
# 1. Setup the fake environment (Mocks)
mock_memory = MagicMock()
mock_get_session.return_value = mock_memory
mock_retrieve_context.return_value = "Mocked context"
mock_build_prompt.return_value = "Mocked prompt"
mock_generate_answer.return_value = "Mocked reply"

# 2. The "Poisoned" Input (Raw Log)
session_id = "test-session-123"
raw_user_input = (
"Jenkins failed at line 42: password=MySuperSecret123 "
"and aws_key=AKIAIOSFODNN7EXAMPLE"
)

# What it SHOULD look like after our new front-door sanitizer
expected_safe_input = (
"Jenkins failed at line 42: password=[REDACTED] "
"and aws_key=[REDACTED_AWS_KEY]"
)

# 3. Fire the function
get_chatbot_reply(session_id=session_id, user_input=raw_user_input)

# 4. THE PROOF: Assert downstream functions only saw the safe, redacted version
mock_retrieve_context.assert_called_once_with(expected_safe_input)
mock_build_prompt.assert_called_once_with(
expected_safe_input, "Mocked context", mock_memory)

# Also prove it doesn't leak into the chat history!
mock_memory.chat_memory.add_user_message.assert_called_once_with(
expected_safe_input)
Loading