diff --git a/packages/cdk/prompts/systemPrompt.txt b/packages/cdk/prompts/systemPrompt.txt index 6e79e4c7..172a3fb9 100644 --- a/packages/cdk/prompts/systemPrompt.txt +++ b/packages/cdk/prompts/systemPrompt.txt @@ -1,49 +1,40 @@ -You are an AI assistant designed to provide guidance and references from your knowledge base to help users make decisions when onboarding. It is *VERY* important you return *ALL* references, for user examination. +# 1. Persona & Logic +You are an AI assistant for onboarding guidance. Follow these strict rules: +* **Strict Evidence:** If the answer is missing, do not infer or use external knowledge. +* **The "List Rule":** If a term (e.g. `on-hold`) exists only in a list/dropdown without a specific definition in the text, you **must** state it is "listed but undefined." Do NOT invent definitions. +* **Decomposition:** Split multi-part queries into numbered sub-questions (Q1, Q2). +* **Correction:** Always output `National Health Service England (NHSE)` instead of `NHSD`. +* **RAG Scores:** `>0.9`: Diamond | `0.8-0.9`: Gold | `0.7-0.8`: Silver | `0.6-0.7`: Bronze | `<0.6`: Scrap (Ignore). +* **Smart Guidance:** If no information can be found, provide next step direction. -# Response -## Response Structure -- *Summary*: 100 characters maximum, capturing core answer -- *Answer* (use "mrkdown") (< 800 characters) -- Page break (use `------`) -- \[Bibliography\] +# 2. Output Structure +1. *Summary:* Concise overview (Max 200 chars). +2. *Answer:* Core response in `mrkdwn` (Max 800 chars). +3. *Next Steps:* If the answer contains no information, provide useful helpful directions. +4. Separator: Use "------" +5. Bibliography: All retrieved documents using the `` template. -## Formatting ("mrkdwn") - a. *Bold* for: - - Headings, subheadings: *Answer:*, *Bibliography:* - - Source names: *NHS England*, *EPS* - b. _Italic_ for: - - Citations, references, document titles - c. Block Quotes for: - - Direct quotes >1 sentence - - Technical specifications, parameters - - Examples - d. `Inline code` for: - - System names, field names: `PrescriptionID` - - Short technical terms: `HL7 FHIR` - e. Links: - - Do not provide links +# 3. Formatting Rules (`mrkdwn`) +Use British English. +* **Bold (`*`):** Headings, Subheadings, Source Names (e.g. `*NHS England*`). +* **Italic (`_`):** Citations and Titles (e.g. `_Guidance v1_`). +* **Blockquote (`>`):** Quotes (>1 sentence) and Tech Specs/Examples. +* **Inline Code (`\``):** System/Field Names and Technical Terms (e.g. `HL7 FHIR`). +* **Links:** `` -# Thinking -## Question Handling -- Detect whether the query contains one or multiple questions -- Split complex queries into individual sub-questions -- Identify question type: factual, procedural, diagnostic, troubleshooting, or clarification-seeking -- For multi-question queries: number sub-questions clearly (Q1, Q2, etc) +# 4. Bibliography Template +Return **ALL** sources using this exact format: +index||summary||excerpt||relevance score -## RAG & Knowledge Base Integration -- Relevance threshold handling: - - Score > 0.85 (High confidence) - - Score 0.70 - 0.85 (Medium confidence) - - Score < 0.70 (Low confidence) +# 5. Example +""" +*Summary* +This is a concise, clear answer - without going into a lot of depth. -## Corrections -- Change _National Health Service Digital (NHSD)_ references to _National Health Service England (NHSE)_ - -# Bibliography -## Format -source number||summary title||link||filename||text snippet||reasoning\n - -## Requirements -- Return **ALL** retrieved documents, their name and a text snippet, from "CONTEXT" -- Get full text references from search results for Bibliography -- Title should be less than 50 characters +*Answer* +A longer answer, going into more detail gained from the knowledge base and using critical thinking. +------ +1||Example name||This is the precise snippet of the pdf file which answers the question.||0.98 +2||Another example file name||A 500 word text excerpt which gives some inference to the answer, but the long citation helps fill in the information for the user, so it's worth the tokens.||0.76 +3||A useless example file's title||This file doesn't contain anything that useful||0.05 +""" diff --git a/packages/cdk/prompts/userPrompt.txt b/packages/cdk/prompts/userPrompt.txt index e7ae7f18..f54a7c2c 100644 --- a/packages/cdk/prompts/userPrompt.txt +++ b/packages/cdk/prompts/userPrompt.txt @@ -1,6 +1,4 @@ -# QUERY -{{user_query}} +{{user_query}} # CONTEXT -## Results $search_results$ -## LIST ALL RESULTS IN TABLE +$search_results$ diff --git a/packages/cdk/resources/BedrockPromptResources.ts b/packages/cdk/resources/BedrockPromptResources.ts index c16b3a07..73a8fa2c 100644 --- a/packages/cdk/resources/BedrockPromptResources.ts +++ b/packages/cdk/resources/BedrockPromptResources.ts @@ -20,14 +20,12 @@ export class BedrockPromptResources extends Construct { constructor(scope: Construct, id: string, props: BedrockPromptResourcesProps) { super(scope, id) - // Nova Pro is recommended for text generation tasks requiring high accuracy and complex understanding. - const novaProModel = BedrockFoundationModel.AMAZON_NOVA_PRO_V1 - // Nova Lite is recommended for tasks - const novaLiteModel = BedrockFoundationModel.AMAZON_NOVA_LITE_V1 + const ragModel = new BedrockFoundationModel("meta.llama3-70b-instruct-v1:0") + const reformulationModel = BedrockFoundationModel.AMAZON_NOVA_LITE_V1 const queryReformulationPromptVariant = PromptVariant.text({ variantName: "default", - model: novaLiteModel, + model: reformulationModel, promptVariables: ["topic"], promptText: props.settings.reformulationPrompt.text }) @@ -41,7 +39,7 @@ export class BedrockPromptResources extends Construct { const ragResponsePromptVariant = PromptVariant.chat({ variantName: "default", - model: novaProModel, + model: ragModel, promptVariables: ["query", "search_results"], system: props.settings.systemPrompt.text, messages: [props.settings.userPrompt] @@ -59,8 +57,8 @@ export class BedrockPromptResources extends Construct { }) // expose model IDs for use in Lambda environment variables - this.ragModelId = novaProModel.modelId - this.queryReformulationModelId = novaLiteModel.modelId + this.ragModelId = ragModel.modelId + this.queryReformulationModelId = reformulationModel.modelId this.queryReformulationPrompt = queryReformulationPrompt this.ragResponsePrompt = ragPrompt diff --git a/packages/slackBotFunction/app/services/bedrock.py b/packages/slackBotFunction/app/services/bedrock.py index 218826a4..44d02019 100644 --- a/packages/slackBotFunction/app/services/bedrock.py +++ b/packages/slackBotFunction/app/services/bedrock.py @@ -42,8 +42,10 @@ def query_bedrock(user_query: str, session_id: str = None) -> RetrieveAndGenerat "type": "KNOWLEDGE_BASE", "knowledgeBaseConfiguration": { "knowledgeBaseId": config.KNOWLEDGEBASE_ID, - "modelArn": config.RAG_MODEL_ID, - "retrievalConfiguration": {"vectorSearchConfiguration": {"numberOfResults": 5}}, + "modelArn": prompt_template.get("model_id", config.RAG_MODEL_ID), + "retrievalConfiguration": { + "vectorSearchConfiguration": {"numberOfResults": 5, "overrideSearchType": "SEMANTIC"} + }, "generationConfiguration": { "guardrailConfiguration": { "guardrailId": config.GUARD_RAIL_ID, @@ -58,16 +60,6 @@ def query_bedrock(user_query: str, session_id: str = None) -> RetrieveAndGenerat } }, }, - "orchestrationConfiguration": { - "inferenceConfig": { - "textInferenceConfig": { - **inference_config, - "stopSequences": [ - "Human:", - ], - } - }, - }, }, }, } @@ -87,6 +79,7 @@ def query_bedrock(user_query: str, session_id: str = None) -> RetrieveAndGenerat else: logger.info("Starting new conversation") + logger.debug("Retrieve and Generate", extra={"params": request_params}) response = client.retrieve_and_generate(**request_params) logger.info( "Got Bedrock response", @@ -100,10 +93,8 @@ def invoke_model(prompt: str, model_id: str, client: BedrockRuntimeClient, infer modelId=model_id, body=json.dumps( { - "anthropic_version": "bedrock-2023-05-31", "temperature": inference_config["temperature"], "top_p": inference_config["topP"], - "top_k": 50, "max_tokens": inference_config["maxTokens"], "messages": [{"role": "user", "content": prompt}], } diff --git a/packages/slackBotFunction/app/services/prompt_loader.py b/packages/slackBotFunction/app/services/prompt_loader.py index e951d447..10d941fb 100644 --- a/packages/slackBotFunction/app/services/prompt_loader.py +++ b/packages/slackBotFunction/app/services/prompt_loader.py @@ -92,7 +92,7 @@ def load_prompt(prompt_name: str, prompt_version: str = None) -> dict: logger.info( f"Loading prompt {prompt_name}' (ID: {prompt_id})", - extra={"prompt_name": prompt_name, "prompt_id": prompt_id, "prompt_version": prompt_version}, + extra={"prompt_version": prompt_version}, ) if is_explicit_version: @@ -100,15 +100,20 @@ def load_prompt(prompt_name: str, prompt_version: str = None) -> dict: else: response = client.get_prompt(promptIdentifier=prompt_id) + logger.info("Prompt Found", extra={"prompt": response}) + + variant = response["variants"][0] + # Extract and render the prompt template - template_config = response["variants"][0]["templateConfiguration"] + template_config = variant["templateConfiguration"] prompt_text = _render_prompt(template_config) actual_version = response.get("version", "DRAFT") # Extract inference configuration with defaults default_inference = {"temperature": 0, "topP": 1, "maxTokens": 1500} - raw_inference = response["variants"][0].get("inferenceConfiguration", {}) - raw_text_config = raw_inference.get("textInferenceConfiguration", {}) + model_id = variant.get("modelId", "") + raw_inference = variant.get("inferenceConfiguration", {}) + raw_text_config = raw_inference.get("text", {}) inference_config = {**default_inference, **raw_text_config} logger.info( @@ -117,10 +122,11 @@ def load_prompt(prompt_name: str, prompt_version: str = None) -> dict: "prompt_name": prompt_name, "prompt_id": prompt_id, "version_used": actual_version, + "model_id": model_id, **inference_config, }, ) - return {"prompt_text": prompt_text, "inference_config": inference_config} + return {"prompt_text": prompt_text, "model_id": model_id, "inference_config": inference_config} except ClientError as e: error_code = e.response.get("Error", {}).get("Code", "Unknown") diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 458107cc..07cb6438 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -199,10 +199,11 @@ def _create_response_body(citations: list[dict[str, str]], feedback_data: dict[s for i, citation in enumerate(citations): result = _create_citation(citation, feedback_data, response_text) - action_buttons.append(result[0]) - response_text = result[1] + action_buttons += result.get("action_buttons", []) + response_text = result.get("response_text", response_text) # Remove any citations that have not been returned + response_text = convert_markdown_to_slack(response_text) response_text = response_text.replace("cit_", "") # Main body @@ -222,45 +223,65 @@ def _create_response_body(citations: list[dict[str, str]], feedback_data: dict[s def _create_citation(citation: dict[str, str], feedback_data: dict, response_text: str): - logger.info("Creating citation", extra={"Citation": citation}) invalid_body = "No document excerpt available." action_buttons = [] - # Create citation blocks ["sourceNumber", "title", "link", "filename", "reference_text"] - title = citation.get("title") or citation.get("filename") or "Source" - body = citation.get("reference_text") or invalid_body - citation_link = citation.get("link") or "" - source_number = (citation.get("source_number", "0")).replace("\n", "") - - # Buttons can only be 75 characters long, truncate to be safe - button_text = f"[{source_number}] {title}" - button = { - "type": "button", - "text": { - "type": "plain_text", - "text": button_text if len(button_text) < 75 else f"{button_text[:70]}...", - }, - "action_id": f"cite_{source_number}", - "value": json.dumps( - { - **feedback_data, - "source_number": source_number, - "title": title, - "body": body, - "link": citation_link, + # Create citation blocks ["source_number", "title", "excerpt", "relevance_score"] + source_number: str = (citation.get("source_number", "0")).replace("\n", "") + title: str = citation.get("title") or citation.get("filename") or "Source" + body: str = citation.get("excerpt") or invalid_body + score: float = float(citation.get("relevance_score") or "0") + + # Format body + body = convert_markdown_to_slack(body) + + if score < 0.6: # low relevance score, skip citation + logger.info("Skipping low relevance citation", extra={"source_number": source_number, "score": score}) + else: + # Buttons can only be 75 characters long, truncate to be safe + button_text = f"[{source_number}] {title}" + button_value = {**feedback_data, "source_number": source_number, "title": title, "body": body, "score": score} + button = { + "type": "button", + "text": { + "type": "plain_text", + "text": button_text if len(button_text) < 75 else f"{button_text[:70]}...", }, - separators=(",", ":"), - ), - } - action_buttons.append(button) + "action_id": f"cite_{source_number}", + "value": json.dumps( + button_value, + separators=(",", ":"), + ), + } + action_buttons.append(button) - # Update inline citations - response_text = response_text.replace( - f"[cit_{source_number}]", - f"<{citation_link}|[{source_number}]>" if citation_link else f"[{source_number}]", - ) + # Update inline citations to remove "cit_" prefix + response_text = response_text.replace(f"[cit_{source_number}]", f"[{source_number}]") + logger.info("Created citation", extra=button_value) + + return {"action_buttons": action_buttons, "response_text": response_text} + + +def convert_markdown_to_slack(body: str) -> str: + """Convert basic markdown to Slack formatting""" + if not body: + return "" + + # 1. Fix common encoding issues + body = body.replace("»", "") + body = body.replace("â¢", "-") + + # 2. Convert Markdown Bold (**text**) and Italics (__text__) + # to Slack Bold (*text*) and Italics (_text_) + body = re.sub(r"(\*|\_){2,10}([^*]+)(\*|\_){2,10}", r"\1\2\1", body) - return [*action_buttons, response_text] + # 3. Handle Lists (Handle various bullet points and dashes, inc. unicode support) + body = re.sub(r"(?:^|\s{1,10})[-•–—▪‣◦⁃]\s{0,10}", r"\n- ", body) + + # 4. Convert Markdown Links [text](url) to Slack + body = re.sub(r"\[([^\]]+)\]\(([^\)]+)\)", r"<\2|\1>", body) + + return body.strip() # ================================================================ @@ -436,14 +457,8 @@ def process_slack_message(event: Dict[str, Any], event_id: str, client: WebClien # Split out citation block if present # Citations are not returned in the object without using `$output_format_instructions$` which overrides the # system prompt. Instead, pull out and format the citations in the prompt manually - prompt_value_keys = [ - "source_number", - "title", - "link", - "filename", - "reference_text", - ] - split = response_text.split("------") # Citations are separated by ------ + prompt_value_keys = ["source_number", "title", "excerpt", "relevance_score"] + split = response_text.split("------") # Citations are separated from main body by ------ citations: list[dict[str, str]] = [] if len(split) != 1: @@ -652,25 +667,11 @@ def open_citation(channel: str, timestamp: str, message: Any, params: Dict[str, body = body.replace("»", "") # Remove double chevrons current_id = f"cite_{source_number}".strip() - selected = False # Reset all button styles, then set the clicked one - for block in blocks: - if block.get("type") == "actions": - for element in block.get("elements", []): - if element.get("type") == "button": - action_id = element.get("action_id") - if action_id == current_id: - # Toggle: if already styled, unselect; else select - if element.get("style") == "primary": - element.pop("style", None) - selected = False - else: - element["style"] = "primary" - selected = True - else: - # Unselect all other buttons - element.pop("style", None) + result = format_blocks(blocks, current_id) + selected = result["selected"] + blocks = result["blocks"] # If selected, insert citation block before feedback if selected: @@ -693,6 +694,36 @@ def open_citation(channel: str, timestamp: str, message: Any, params: Dict[str, logger.error(f"Error updating message for citation: {e}", extra={"error": traceback.format_exc()}) +def format_blocks(blocks: Any, current_id: str): + """Format blocks by styling the selected citation button and unstyle others""" + selected = False + + for block in blocks: + if block.get("type") != "actions": + continue + + for element in block.get("elements", []): + if element.get("type") != "button": + continue + + if element.get("action_id") == current_id: + selected = _toggle_button_style(element) + else: + element.pop("style", None) + + return {"selected": selected, "blocks": blocks} + + +def _toggle_button_style(element: dict) -> bool: + """Toggle button style and return whether it's now selected""" + if element.get("style") == "primary": + element.pop("style", None) + return False + else: + element["style"] = "primary" + return True + + # ================================================================ # Session management # ================================================================ diff --git a/packages/slackBotFunction/tests/test_slack_events.py b/packages/slackBotFunction/tests/test_slack_events.py deleted file mode 100644 index a4398249..00000000 --- a/packages/slackBotFunction/tests/test_slack_events.py +++ /dev/null @@ -1,868 +0,0 @@ -import sys -import pytest -from unittest.mock import Mock, patch, MagicMock, call - - -@pytest.fixture -def mock_logger(): - return MagicMock() - - -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") -def test_process_async_slack_event_normal_message( - mock_forward_event_to_pull_request_lambda: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test successful async event processing""" - # set up mocks - mock_client = Mock() - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_async_slack_event - - # perform operation - slack_event_data = {"text": "<@U123> test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} - with patch("app.slack.slack_events.process_feedback_event") as mock_process_feedback_event, patch( - "app.slack.slack_events.process_slack_message" - ) as mock_process_slack_message: - process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) - mock_forward_event_to_pull_request_lambda.assert_not_called() - mock_process_feedback_event.assert_not_called() - mock_process_slack_message.assert_called_once_with( - event=slack_event_data, event_id="evt123", client=mock_client - ) - - -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") -def test_process_async_slack_event_pull_request_with_mention( - mock_forward_event_to_pull_request_lambda: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test successful async event processing""" - # set up mocks - mock_client = Mock() - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_async_slack_event - - # perform operation - slack_event_data = { - "text": "<@U123> pr: 123 test question", - "user": "U456", - "channel": "C789", - "ts": "1234567890.123", - } - with patch("app.slack.slack_events.process_feedback_event") as mock_process_feedback_event, patch( - "app.slack.slack_events.process_slack_message" - ) as mock_process_slack_message: - process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) - mock_forward_event_to_pull_request_lambda.assert_called_once_with( - pull_request_id="123", - event=slack_event_data, - event_id="evt123", - store_pull_request_id=True, - ) - mock_process_feedback_event.assert_not_called() - mock_process_slack_message.assert_not_called() - - -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") -def test_process_async_slack_event_pull_request_with_no_mention( - mock_forward_event_to_pull_request_lambda: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test successful async event processing""" - # set up mocks - mock_client = Mock() - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_async_slack_event - - # perform operation - slack_event_data = { - "text": "pr: 123 test question", - "user": "U456", - "channel": "C789", - "ts": "1234567890.123", - } - with patch("app.slack.slack_events.process_feedback_event") as mock_process_feedback_event, patch( - "app.slack.slack_events.process_slack_message" - ) as mock_process_slack_message: - process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) - mock_forward_event_to_pull_request_lambda.assert_called_once_with( - pull_request_id="123", - event=slack_event_data, - event_id="evt123", - store_pull_request_id=True, - ) - mock_process_feedback_event.assert_not_called() - mock_process_slack_message.assert_not_called() - - -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") -def test_process_async_slack_event_feedback( - mock_forward_event_to_pull_request_lambda: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test successful async event processing""" - # set up mocks - mock_client = Mock() - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_async_slack_event - - # perform operation - slack_event_data = { - "text": "feedback: this is some feedback", - "user": "U456", - "channel": "C789", - "ts": "1234567890.123", - } - with patch("app.slack.slack_events.process_feedback_event") as mock_process_feedback_event, patch( - "app.slack.slack_events.process_slack_message" - ) as mock_process_slack_message: - process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) - mock_forward_event_to_pull_request_lambda.assert_not_called() - mock_process_feedback_event.assert_called_once_with( - message_text="feedback: this is some feedback", - conversation_key="thread#C789#1234567890.123", - user_id="U456", - channel_id="C789", - thread_root="1234567890.123", - client=mock_client, - event=slack_event_data, - ) - mock_process_slack_message.assert_not_called() - - -def test_process_slack_message_empty_query(mock_get_parameter: Mock, mock_env: Mock): - """Test async event processing with empty query""" - # set up mocks - mock_client = Mock() - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_slack_message - - # perform operation - slack_event_data = { - "text": "<@U123>", # Only mention, no actual query - "user": "U456", - "channel": "C789", - "ts": "1234567890.123", - } - process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) - - # assertions - mock_client.chat_postMessage.assert_called_once_with( - channel="C789", - text="Hi there! Please ask me a question and I'll help you find information from our knowledge base.", - thread_ts="1234567890.123", - ) - - -@patch("app.services.dynamo.get_state_information") -@patch("app.services.ai_processor.process_ai_query") -@patch("app.slack.slack_events.get_conversation_session") -@patch("app.services.slack.post_error_message") -def test_process_slack_message_event_error( - mock_post_error_message: Mock, - mock_get_session: Mock, - mock_process_ai_query: Mock, - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test async event processing with error""" - # set up mocks - mock_process_ai_query.side_effect = Exception("AI processing error") - mock_get_session.return_value = None # No existing session - mock_client = Mock() - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_slack_message - - # perform operation - slack_event_data = {"text": "test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} - process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) - - # assertions - mock_post_error_message.assert_called_once_with(channel="C789", thread_ts="1234567890.123", client=mock_client) - - -@patch("app.services.dynamo.get_state_information") -@patch("app.services.ai_processor.process_ai_query") -@patch("app.slack.slack_events.get_conversation_session") -def test_process_slack_message_with_thread_ts( - mock_get_session: Mock, - mock_process_ai_query: Mock, - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test async event processing with existing thread_ts""" - # set up mocks - mock_client = Mock() - mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} - mock_client.chat_update.return_value = {"ok": True} - mock_process_ai_query.return_value = { - "text": "AI response", - "session_id": "session-123", - "citations": [], - "kb_response": {"output": {"text": "AI response"}}, - } - mock_get_session.return_value = None # No existing session - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_slack_message - - # perform operation - slack_event_data = { - "text": "<@U123> test question", - "user": "U456", - "channel": "C789", - "ts": "1234567890.123", - "thread_ts": "1234567888.111", # Existing thread - } - process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) - - # assertions - # Should be called at least once with the correct thread_ts - assert mock_client.chat_postMessage.call_count >= 1 - first_call = mock_client.chat_postMessage.call_args_list[0] - assert first_call[1]["thread_ts"] == "1234567888.111" - assert first_call[1]["text"] == "AI response" - - -@patch("app.services.dynamo.get_state_information") -@patch("app.services.ai_processor.process_ai_query") -@patch("app.slack.slack_events.get_conversation_session") -def test_regex_text_processing( - mock_get_session: Mock, - mock_process_ai_query: Mock, - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test regex text processing functionality within process_async_slack_event""" - # set up mocks - mock_client = Mock() - mock_process_ai_query.return_value = { - "text": "AI response", - "session_id": "session-123", - "citations": [], - "kb_response": {"output": {"text": "AI response"}}, - } - mock_get_session.return_value = None # No existing session - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_slack_message - - # perform operation - slack_event_data = {"text": "<@U123456> test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} - - process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) - - # assertions - # Verify that the message was processed (process_ai_query was called) - mock_process_ai_query.assert_called_once() - # The actual regex processing happens inside the function - assert mock_client.chat_postMessage.called - - -@patch("app.services.dynamo.get_state_information") -@patch("app.services.ai_processor.process_ai_query") -@patch("app.slack.slack_events.get_conversation_session") -@patch("app.slack.slack_events._create_feedback_blocks") -def test_citation_processing( - mock_get_session: Mock, - mock_process_ai_query: Mock, - mock_create_feedback_blocks: Mock, - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test block builder is being called correctly""" - # set up mocks - mock_client = Mock() - mock_process_ai_query.return_value = { - "text": "AI response", - "session_id": "session-123", - "citations": [], - "kb_response": {"output": {"text": "AI response"}}, - } - mock_get_session.return_value = None # No existing session - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_slack_message - - # perform operation - slack_event_data = { - "text": "Answer", - "user": "U456", - "channel": "C789", - "ts": "1234567890.123", - } - - process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) - - # assertions - # Verify that the message was processed (process_ai_query was called) - mock_create_feedback_blocks.assert_called_once() - - -@patch("app.services.dynamo.get_state_information") -@patch("app.services.ai_processor.process_ai_query") -@patch("app.slack.slack_events.get_conversation_session") -@patch("app.slack.slack_events._create_feedback_blocks") -def test_citation_logging( - mock_get_session: Mock, - mock_create_feedback_blocks: Mock, - mock_process_ai_query: Mock, - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, - mock_logger, -): - """Test block builder is being called correctly""" - with patch("app.core.config.get_logger", return_value=mock_logger): - # set up mocks - mock_client = Mock() - mock_process_ai_query.return_value = { - "text": "AI response\n------\ntest", - "session_id": "session-123", - "citations": [], - "kb_response": {"output": {"text": "AI response"}}, - } - mock_get_session.return_value = None # No existing session - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_slack_message - - # perform operation - slack_event_data = { - "text": "AI response", - "user": "U456", - "channel": "C789", - "ts": "1234567890.123", - } - - process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) - - # assertions - mock_logger.info.assert_has_calls( - [ - call( - "Processing message from user U456", - extra={ - "user_query": "AI response", - "conversation_key": "thread#C789#1234567890.123", - "event_id": "evt123", - }, - ), - # Found citations to split - call("Found citation(s)", extra={"Raw Citations": ["test"]}), - # Citations parsed correctly - call("Parsed citation(s)", extra={"citations": [{"source_number": "test"}]}), - ] - ) - - -@patch("app.services.dynamo.get_state_information") -def test_citation_creation( - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test citations are being added via Slack blocks correctly""" - # set up mocks - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import _create_feedback_blocks - - _sourceNumber = "5" - _title = "Some Title Summarising the Document" - _link = "http://example.com" - _filename = "example.pdf" - _text_snippet = "This is some example text, maybe something about NHSE" - - result = _create_feedback_blocks( - response_text="Answer", - citations=[ - { - "source_number": _sourceNumber, - "title": _title, - "link": _link, - "filename": _filename, - "reference_text": _text_snippet, - } - ], - conversation_key="12345", - channel="C789", - message_ts="123", - thread_ts="123", - ) - - # assertions - # Verify that the citation button was added - citation_section = result[1] - assert citation_section is not None - - # Verify button is correct - assert citation_section["type"] == "actions" - assert citation_section["block_id"] == "citation_actions" - assert citation_section["elements"] and len(citation_section["elements"]) > 0 - - # Verify that the citation data is correct - citation_button = citation_section["elements"][0] - assert citation_button is not None - - assert citation_button["type"] == "button" - assert citation_button["text"]["text"] == f"[{_sourceNumber}] {_title}" - - assert f'"source_number":"{_sourceNumber}"' in citation_button["value"] - assert f'"title":"{_title}"' in citation_button["value"] - assert f'"body":"{_text_snippet}"' in citation_button["value"] - assert f'"link":"{_link}"' in citation_button["value"] - - -@patch("app.services.dynamo.get_state_information") -def test_citation_creation_defaults( - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test regex text processing functionality within process_async_slack_event""" - # set up mocks - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import _create_feedback_blocks - - result = _create_feedback_blocks( - response_text="Answer", - citations=[{}], # Pass in empty object - conversation_key="12345", - channel="C789", - message_ts="123", - thread_ts="123", - ) - - # assertions - # Verify that the citation button was added - citation_section = result[1] - assert citation_section is not None - - # Verify that the citation data is correct - citation_button = citation_section["elements"][0] - assert citation_button is not None - - assert citation_button["type"] == "button" - assert citation_button["text"]["text"] == "[0] Source" - - assert '"source_number":"0"' in citation_button["value"] - assert '"title":"Source"' in citation_button["value"] - assert '"body":"No document excerpt available."' in citation_button["value"] - assert '"link":""' in citation_button["value"] - - -@patch("app.services.dynamo.get_state_information") -def test_response_handle_links( - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test regex text processing citation links in response body""" - # set up mocks - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import _create_feedback_blocks - - result = _create_feedback_blocks( - response_text="[cit_0]", - citations=[ - { - "source_number": "0", - "link": "https://example.com", - } - ], - conversation_key="12345", - channel="C789", - message_ts="123", - thread_ts="123", - ) - - # assertions - # Verify links in the body are changed to slack links - citation_section = result[0] - assert citation_section is not None - - assert "" in citation_section["text"]["text"] - - -@patch("app.services.dynamo.get_state_information") -@patch("app.services.dynamo.store_state_information") -@patch("app.services.ai_processor.process_ai_query") -def test_process_slack_message_with_session_storage( - mock_process_ai_query: Mock, - mock_store_state_information: Mock, - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test async event processing that stores a new session""" - # set up mocks - mock_client = Mock() - mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} - mock_client.chat_update.return_value = {"ok": True} - mock_process_ai_query.return_value = { - "text": "AI response", - "session_id": "new-session-123", - "citations": [], - "kb_response": {"output": {"text": "AI response"}, "sessionId": "new-session-123"}, - } - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_slack_message - - # perform operation - slack_event_data = {"text": "test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} - - process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) - - # assertions - # Verify session was stored - should be called twice (Q&A pair + session) - assert mock_store_state_information.call_count >= 1 - - -@patch("app.services.dynamo.get_state_information") -@patch("app.services.ai_processor.process_ai_query") -@patch("app.slack.slack_events.get_conversation_session") -def test_process_slack_message_chat_update_error( - mock_get_session: Mock, - mock_process_ai_query: Mock, - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test process_async_slack_event with chat_update error""" - # set up mocks - mock_client = Mock() - mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} - mock_client.chat_update.side_effect = Exception("Update failed") - mock_process_ai_query.return_value = { - "text": "AI response", - "session_id": "session-123", - "citations": [], - "kb_response": {"output": {"text": "AI response"}}, - } - mock_get_session.return_value = None # No existing session - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_slack_message - - # perform operation - slack_event_data = {"text": "<@U123> test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} - process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) - - # assertions - # no assertions as we are just checking it does not throw an error - - -@patch("app.services.dynamo.get_state_information") -@patch("app.services.ai_processor.process_ai_query") -@patch("app.slack.slack_events.get_conversation_session") -def test_process_slack_message_dm_context( - mock_get_session: Mock, - mock_process_ai_query: Mock, - mock_get_state_information: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test process_async_slack_event with DM context""" - # set up mocks - mock_client = Mock() - mock_client.chat_postMessage.return_value = {"ts": "123"} - mock_process_ai_query.return_value = { - "text": "AI response", - "session_id": "new-session", - "citations": [], - "kb_response": {"output": {"text": "AI response"}, "sessionId": "new-session"}, - } - mock_get_session.return_value = None - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_async_slack_event - - # perform operation - slack_event_data = { - "text": "test question", - "user": "U456", - "channel": "D789", - "ts": "123", - "channel_type": "im", # DM context - } - process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) - - # assertions - # no assertions as we are just checking it does not throw an error - - -@patch("app.utils.handler_utils.is_latest_message") -def test_process_async_slack_action_positive( - mock_is_latest_message: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test successful async action processing""" - # set up mocks - mock_client = Mock() - mock_is_latest_message.return_value = True - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_async_slack_action - - feedback_value = '{"ck":"thread#C123#123","ch":"C123","mt":"1759845126.972219","tt":"1759845114.407989"}' - - # perform operation - slack_action_data = { - "type": "block_actions", - "user": {"id": "U123"}, - "channel": {"id": "C123"}, - "message": {"ts": "1759845126.972219"}, - "actions": [{"action_id": "feedback_yes", "value": feedback_value}], - } - with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: - process_async_slack_action(body=slack_action_data, client=mock_client) - - # assertions - mock_store_feedback.assert_called_once_with( - conversation_key="thread#C123#123", - feedback_type="positive", - user_id="U123", - channel_id="C123", - thread_ts="1759845114.407989", - message_ts="1759845126.972219", - client=mock_client, - ) - mock_client.chat_postMessage.assert_called_once_with( - channel="C123", - text="Thank you for your feedback.", - thread_ts="1759845114.407989", - ) - - -@patch("app.utils.handler_utils.is_latest_message") -def test_process_async_slack_action_negative( - mock_is_latest_message: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test successful async action processing""" - # set up mocks - mock_client = Mock() - mock_is_latest_message.return_value = True - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_async_slack_action - - feedback_value = '{"ck":"thread#C123#123","ch":"C123","mt":"1759845126.972219","tt":"1759845114.407989"}' - - # perform operation - slack_action_data = { - "type": "block_actions", - "user": {"id": "U123"}, - "channel": {"id": "C123"}, - "message": {"ts": "1759845126.972219"}, - "actions": [{"action_id": "feedback_no", "value": feedback_value}], - } - with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: - process_async_slack_action(body=slack_action_data, client=mock_client) - - # assertions - mock_store_feedback.assert_called_once_with( - conversation_key="thread#C123#123", - feedback_type="negative", - user_id="U123", - channel_id="C123", - thread_ts="1759845114.407989", - message_ts="1759845126.972219", - client=mock_client, - ) - mock_client.chat_postMessage.assert_called_once_with( - channel="C123", - text='Please let us know how the answer could be improved. Start your message with "feedback:"', - thread_ts="1759845114.407989", - ) - - -@patch("app.utils.handler_utils.is_latest_message") -def test_process_async_slack_action_not_latest( - mock_is_latest_message: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test successful async action processing""" - # set up mocks - mock_client = Mock() - mock_is_latest_message.return_value = False - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_async_slack_action - - feedback_value = '{"ck":"thread#C123#123","ch":"C123","mt":"1759845126.972219","tt":"1759845114.407989"}' - - # perform operation - slack_action_data = { - "type": "block_actions", - "user": {"id": "U123"}, - "channel": {"id": "C123"}, - "actions": [{"action_id": "feedback_no", "value": feedback_value}], - } - with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: - process_async_slack_action(body=slack_action_data, client=mock_client) - - # assertions - mock_store_feedback.assert_not_called() - mock_client.chat_postMessage.assert_not_called() - - -@patch("app.utils.handler_utils.is_latest_message") -def test_process_async_slack_action_unknown_action( - mock_is_latest_message: Mock, - mock_get_parameter: Mock, - mock_env: Mock, -): - """Test successful async action processing""" - # set up mocks - mock_client = Mock() - mock_is_latest_message.return_value = True - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_async_slack_action - - feedback_value = '{"ck":"thread#C123#123","ch":"C123","mt":"1759845126.972219","tt":"1759845114.407989"}' - - # perform operation - slack_action_data = { - "type": "block_actions", - "user": {"id": "U123"}, - "channel": {"id": "C123"}, - "actions": [{"action_id": "I_Do_Not_Know_This_Action", "value": feedback_value}], - } - with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: - process_async_slack_action(body=slack_action_data, client=mock_client) - - # assertions - mock_store_feedback.assert_not_called() - mock_client.chat_postMessage.assert_not_called() - - -def test_process_feedback_event(): - # set up mocks - mock_client = Mock() - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_feedback_event - - # perform operation - mock_event = {} - with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: - process_feedback_event( - message_text="feedback: this is some feedback", - conversation_key="thread#C123#123", - user_id="U123", - channel_id="C123", - thread_root="1759845114.407989", - event=mock_event, - client=mock_client, - ) - - # assertions - mock_store_feedback.assert_called_once_with( - conversation_key="thread#C123#123", - feedback_type="additional", - user_id="U123", - channel_id="C123", - thread_ts="1759845114.407989", - message_ts=None, - feedback_text="this is some feedback", - client=mock_client, - ) - mock_client.chat_postMessage.assert_called_once_with( - channel="C123", text="Thank you for your feedback.", thread_ts="1759845114.407989" - ) - - -@patch("app.services.slack.post_error_message") -def test_process_feedback_event_error( - mock_post_error_message: Mock, -): - # set up mocks - mock_client = Mock() - - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_feedback_event - - # perform operation - mock_event = { - "channel": "C123", - "thread_ts": "123", - } - with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: - mock_store_feedback.side_effect = Exception("There was an error") - process_feedback_event( - message_text="feedback: this is some feedback", - conversation_key="thread#C123#123", - user_id="U123", - channel_id="C123", - thread_root="1759845114.407989", - event=mock_event, - client=mock_client, - ) - - # assertions - mock_post_error_message.assert_called_once_with(channel="C123", thread_ts="123", client=mock_client) diff --git a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_actions.py b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_actions.py new file mode 100644 index 00000000..635920e7 --- /dev/null +++ b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_actions.py @@ -0,0 +1,276 @@ +import sys +import pytest +from unittest.mock import Mock, patch, MagicMock + + +@pytest.fixture +def mock_logger(): + return MagicMock() + + +@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +def test_process_async_slack_event_feedback( + mock_forward_event_to_pull_request_lambda: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test successful async event processing""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_event + + # perform operation + slack_event_data = { + "text": "feedback: this is some feedback", + "user": "U456", + "channel": "C789", + "ts": "1234567890.123", + } + with patch("app.slack.slack_events.process_feedback_event") as mock_process_feedback_event, patch( + "app.slack.slack_events.process_slack_message" + ) as mock_process_slack_message: + process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) + mock_forward_event_to_pull_request_lambda.assert_not_called() + mock_process_feedback_event.assert_called_once_with( + message_text="feedback: this is some feedback", + conversation_key="thread#C789#1234567890.123", + user_id="U456", + channel_id="C789", + thread_root="1234567890.123", + client=mock_client, + event=slack_event_data, + ) + mock_process_slack_message.assert_not_called() + + +@patch("app.utils.handler_utils.is_latest_message") +def test_process_async_slack_action_positive( + mock_is_latest_message: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test successful async action processing""" + # set up mocks + mock_client = Mock() + mock_is_latest_message.return_value = True + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_action + + feedback_value = '{"ck":"thread#C123#123","ch":"C123","mt":"1759845126.972219","tt":"1759845114.407989"}' + + # perform operation + slack_action_data = { + "type": "block_actions", + "user": {"id": "U123"}, + "channel": {"id": "C123"}, + "message": {"ts": "1759845126.972219"}, + "actions": [{"action_id": "feedback_yes", "value": feedback_value}], + } + with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: + process_async_slack_action(body=slack_action_data, client=mock_client) + + # assertions + mock_store_feedback.assert_called_once_with( + conversation_key="thread#C123#123", + feedback_type="positive", + user_id="U123", + channel_id="C123", + thread_ts="1759845114.407989", + message_ts="1759845126.972219", + client=mock_client, + ) + mock_client.chat_postMessage.assert_called_once_with( + channel="C123", + text="Thank you for your feedback.", + thread_ts="1759845114.407989", + ) + + +@patch("app.utils.handler_utils.is_latest_message") +def test_process_async_slack_action_negative( + mock_is_latest_message: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test successful async action processing""" + # set up mocks + mock_client = Mock() + mock_is_latest_message.return_value = True + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_action + + feedback_value = '{"ck":"thread#C123#123","ch":"C123","mt":"1759845126.972219","tt":"1759845114.407989"}' + + # perform operation + slack_action_data = { + "type": "block_actions", + "user": {"id": "U123"}, + "channel": {"id": "C123"}, + "message": {"ts": "1759845126.972219"}, + "actions": [{"action_id": "feedback_no", "value": feedback_value}], + } + with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: + process_async_slack_action(body=slack_action_data, client=mock_client) + + # assertions + mock_store_feedback.assert_called_once_with( + conversation_key="thread#C123#123", + feedback_type="negative", + user_id="U123", + channel_id="C123", + thread_ts="1759845114.407989", + message_ts="1759845126.972219", + client=mock_client, + ) + mock_client.chat_postMessage.assert_called_once_with( + channel="C123", + text='Please let us know how the answer could be improved. Start your message with "feedback:"', + thread_ts="1759845114.407989", + ) + + +@patch("app.utils.handler_utils.is_latest_message") +def test_process_async_slack_action_not_latest( + mock_is_latest_message: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test successful async action processing""" + # set up mocks + mock_client = Mock() + mock_is_latest_message.return_value = False + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_action + + feedback_value = '{"ck":"thread#C123#123","ch":"C123","mt":"1759845126.972219","tt":"1759845114.407989"}' + + # perform operation + slack_action_data = { + "type": "block_actions", + "user": {"id": "U123"}, + "channel": {"id": "C123"}, + "actions": [{"action_id": "feedback_no", "value": feedback_value}], + } + with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: + process_async_slack_action(body=slack_action_data, client=mock_client) + + # assertions + mock_store_feedback.assert_not_called() + mock_client.chat_postMessage.assert_not_called() + + +@patch("app.utils.handler_utils.is_latest_message") +def test_process_async_slack_action_unknown_action( + mock_is_latest_message: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test successful async action processing""" + # set up mocks + mock_client = Mock() + mock_is_latest_message.return_value = True + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_action + + feedback_value = '{"ck":"thread#C123#123","ch":"C123","mt":"1759845126.972219","tt":"1759845114.407989"}' + + # perform operation + slack_action_data = { + "type": "block_actions", + "user": {"id": "U123"}, + "channel": {"id": "C123"}, + "actions": [{"action_id": "I_Do_Not_Know_This_Action", "value": feedback_value}], + } + with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: + process_async_slack_action(body=slack_action_data, client=mock_client) + + # assertions + mock_store_feedback.assert_not_called() + mock_client.chat_postMessage.assert_not_called() + + +def test_process_feedback_event(): + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_feedback_event + + # perform operation + mock_event = {} + with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: + process_feedback_event( + message_text="feedback: this is some feedback", + conversation_key="thread#C123#123", + user_id="U123", + channel_id="C123", + thread_root="1759845114.407989", + event=mock_event, + client=mock_client, + ) + + # assertions + mock_store_feedback.assert_called_once_with( + conversation_key="thread#C123#123", + feedback_type="additional", + user_id="U123", + channel_id="C123", + thread_ts="1759845114.407989", + message_ts=None, + feedback_text="this is some feedback", + client=mock_client, + ) + mock_client.chat_postMessage.assert_called_once_with( + channel="C123", text="Thank you for your feedback.", thread_ts="1759845114.407989" + ) + + +@patch("app.services.slack.post_error_message") +def test_process_feedback_event_error( + mock_post_error_message: Mock, +): + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_feedback_event + + # perform operation + mock_event = { + "channel": "C123", + "thread_ts": "123", + } + with patch("app.slack.slack_events.store_feedback") as mock_store_feedback: + mock_store_feedback.side_effect = Exception("There was an error") + process_feedback_event( + message_text="feedback: this is some feedback", + conversation_key="thread#C123#123", + user_id="U123", + channel_id="C123", + thread_root="1759845114.407989", + event=mock_event, + client=mock_client, + ) + + # assertions + mock_post_error_message.assert_called_once_with(channel="C123", thread_ts="123", client=mock_client) diff --git a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_citations.py b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_citations.py new file mode 100644 index 00000000..8b256e0f --- /dev/null +++ b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_citations.py @@ -0,0 +1,685 @@ +import json +import sys +import pytest +from unittest.mock import Mock, MagicMock, patch + + +@pytest.fixture +def mock_logger(): + return MagicMock() + + +@patch("app.services.dynamo.get_state_information") +@patch("app.services.ai_processor.process_ai_query") +@patch("app.slack.slack_events.get_conversation_session") +@patch("app.slack.slack_events._create_feedback_blocks") +def test_citation_processing( + mock_get_session: Mock, + mock_process_ai_query: Mock, + mock_create_feedback_blocks: Mock, + mock_get_state_information: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test block builder is being called correctly""" + # set up mocks + mock_client = Mock() + mock_process_ai_query.return_value = { + "text": "AI response", + "session_id": "session-123", + "citations": [], + "kb_response": {"output": {"text": "AI response"}}, + } + mock_get_session.return_value = None # No existing session + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_slack_message + + # perform operation + slack_event_data = { + "text": "Answer", + "user": "U456", + "channel": "C789", + "ts": "1234567890.123", + } + + process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) + + # assertions + # Verify that the message was processed (process_ai_query was called) + mock_create_feedback_blocks.assert_called_once() + + +def test_process_slack_message_split_citation(): + # set up mocks + mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} + mock_client.chat_update.return_value = {"ok": True} + + +def test_process_citation_events_update_chat(): + # set up mocks + mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} + mock_client.chat_update.return_value = {"ok": True} + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_action + + body = { + "type": "block_actions", + "message": { + "ts": "123", + "text": "", + "blocks": [ + { + "type": "section", + "block_id": "OvNCm", + "text": { + "type": "mrkdwn", + "text": "", + }, + }, + { + "type": "actions", + "block_id": "citation_actions", + "elements": [ + { + "type": "button", + "action_id": "cite_1", + "text": { + "type": "plain_text", + "text": "[1] Downloading a single prescription using the prescription's ID, or ...", + "emoji": "true", + }, + "value": '{"ck":"123","ch":"123","mt":"123","tt":"123","source_number":"1","title":"title"', + } + ], + }, + ], + }, + "channel": { + "id": "ABC123", + }, + "actions": [ + { + "action_id": "cite_1", + "block_id": "citation_actions", + "text": { + "type": "plain_text", + "text": "[1] Downloading a single prescription using the prescription's ID, or ...", + "emoji": "true", + }, + "value": '{"ck":"123","ch":"C095D4SRX6W","mt":"123","tt":"123","source_number":"1","title":""}', + "type": "button", + "action_ts": "1765807735.805872", + } + ], + } + + # perform operation + process_async_slack_action(body, mock_client) + + # assertions + mock_client.chat_update.assert_called() + + +def test_process_citation_events_update_chat_message_open_citation(): + # set up mocks + mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} + mock_client.chat_update.return_value = {"ok": True} + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import open_citation + + params = { + "ck": "123123", + "ch": "123123", + "mt": "123123.123123", + "tt": "123123.123123", + "source_number": "1", + "title": "Citation Title", + "body": "Citation Body", + "relevance_score": "0.95", + } + + citations = { + "type": "actions", + "block_id": "citation_actions", + "elements": [ + { + "type": "button", + "action_id": "cite_1", + "text": { + "type": "plain_text", + "text": "[1] The body of the citation", + "emoji": "true", + }, + "style": None, # Set citation as de-active + "value": str(params), + }, + ], + } + + message = { + "blocks": [citations], + } + + # perform operation + open_citation("ABC", "123", message, params, mock_client) + + # assertions + expected_blocks = [ + citations, + { + "type": "section", + "text": {"type": "mrkdwn", "text": "*Citation Title*\n\n> Citation Body"}, + "block_id": "citation_block", + }, + ] + mock_client.chat_update.assert_called() + mock_client.chat_update.assert_called_with(channel="ABC", ts="123", blocks=expected_blocks) + + +def test_process_citation_events_update_chat_message_close_citation(): + # set up mocks + mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} + mock_client.chat_update.return_value = {"ok": True} + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import open_citation + + params = { + "ck": "123123", + "ch": "123123", + "mt": "123123.123123", + "tt": "123123.123123", + "source_number": "1", + "title": "Citation Title", + "body": "Citation Body", + "relevance_score": "0.95", + } + + citations = { + "type": "actions", + "block_id": "citation_actions", + "elements": [ + { + "type": "button", + "action_id": "cite_1", + "text": { + "type": "plain_text", + "text": "[1] The body of the citation", + "emoji": "true", + }, + "style": "primary", # Set citation as active + "value": str(params), + }, + ], + } + + citation_body = { + "type": "section", + "text": {"type": "mrkdwn", "text": "*Citation Title*\n\n> Citation Body"}, + "block_id": "citation_block", + } + + message = { + "blocks": [citations, citation_body], + } + + # perform operation + open_citation("ABC", "123", message, params, mock_client) + + # assertions + expected_blocks = [ + citations, + ] + mock_client.chat_update.assert_called() + mock_client.chat_update.assert_called_with(channel="ABC", ts="123", blocks=expected_blocks) + + +def test_process_citation_events_update_chat_message_change_close_citation(): + # set up mocks + mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} + mock_client.chat_update.return_value = {"ok": True} + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import open_citation + + params = { + "ck": "123123", + "ch": "123123", + "mt": "123123.123123", + "tt": "123123.123123", + "source_number": "2", + "title": "Second Citation Title", + "body": "Second Citation Body", + "relevance_score": "0.95", + } + + citations = { + "type": "actions", + "block_id": "citation_actions", + "elements": [ + { + "type": "button", + "action_id": "cite_1", + "text": { + "type": "plain_text", + "text": "[1] The body of the citation", + "emoji": "true", + }, + "style": "primary", # Set citation as active + "value": str(params), + }, + { + "type": "button", + "action_id": "cite_2", + "text": { + "type": "plain_text", + "text": "[2] The body of the citation", + "emoji": "true", + }, + "style": None, # Set citation as active + "value": str(params), + }, + ], + } + + first_citation_body = { + "type": "section", + "text": {"type": "mrkdwn", "text": "*First Citation Title*\n\n> First Citation Body"}, + "block_id": "citation_block", + } + + second_citation_body = { + "type": "section", + "text": {"type": "mrkdwn", "text": "*Second Citation Title*\n\n> Second Citation Body"}, + "block_id": "citation_block", + } + + message = { + "blocks": [citations, first_citation_body], + } + + # perform operation + open_citation("ABC", "123", message, params, mock_client) + + # assertions + expected_blocks = [citations, second_citation_body] + mock_client.chat_update.assert_called() + mock_client.chat_update.assert_called_with(channel="ABC", ts="123", blocks=expected_blocks) + + +def test_create_response_body_no_error_without_citations( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + # perform operation + _create_response_body( + citations=[], + feedback_data={}, + response_text="This is a response without a citation.[1]", + ) + + # assertions + # no assertions as we are just checking it does not throw an error + + +def test_create_response_body_creates_body_without_citations( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + # perform operation + response = _create_response_body( + citations=[], + feedback_data={}, + response_text="This is a response without a citation.", + ) + + # assertions + assert len(response) > 0 + assert response[0]["type"] == "section" + assert "This is a response without a citation." in response[0]["text"]["text"] + + +def test_create_response_body_update_body_with_citations( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + # perform operation + response = _create_response_body( + citations=[ + { + "source_number": "1", + "title": "Citation Title", + "body": "Citation Body", + "relevance_score": "0.95", + } + ], + feedback_data={}, + response_text="This is a response with a citation.[1]", + ) + + # assertions + assert len(response) > 1 + assert response[1]["type"] == "actions" + assert response[1]["block_id"] == "citation_actions" + + citation_element = response[1]["elements"][0] + assert citation_element["type"] == "button" + assert citation_element["action_id"] == "cite_1" + assert "[1] Citation Title" in citation_element["text"]["text"] + + +def test_create_response_body_creates_body_with_multiple_citations( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + # perform operation + response = _create_response_body( + citations=[ + { + "source_number": "1", + "title": "Citation Title", + "body": "Citation Body", + "relevance_score": "0.95", + }, + { + "source_number": "2", + "title": "Citation Title", + "body": "Citation Body", + "relevance_score": "0.95", + }, + ], + feedback_data={}, + response_text="This is a response with a citation.[1]", + ) + + # assertions + assert len(response) > 1 + assert response[1]["type"] == "actions" + assert response[1]["block_id"] == "citation_actions" + + first_citation_element = response[1]["elements"][0] + assert first_citation_element["type"] == "button" + assert first_citation_element["action_id"] == "cite_1" + assert "[1] Citation Title" in first_citation_element["text"]["text"] + + second_citation_element = response[1]["elements"][1] + assert second_citation_element["type"] == "button" + assert second_citation_element["action_id"] == "cite_2" + assert "[2] Citation Title" in second_citation_element["text"]["text"] + + +def test_create_response_body_creates_body_ignoring_low_score_citations( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + # perform operation + response = _create_response_body( + citations=[ + { + "source_number": "1", + "title": "Citation Title", + "body": "Citation Body", + "relevance_score": "0.55", + }, + { + "source_number": "2", + "title": "Citation Title", + "body": "Citation Body", + "relevance_score": "0.95", + }, + ], + feedback_data={}, + response_text="This is a response with a citation.[1]", + ) + + # assertions + assert len(response) > 1 + assert response[1]["type"] == "actions" + assert response[1]["block_id"] == "citation_actions" + + citation_elements = response[1]["elements"] + assert len(citation_elements) == 1 + + citation_element = citation_elements[0] + assert citation_element["type"] == "button" + assert citation_element["action_id"] == "cite_2" + assert "[2] Citation Title" in citation_element["text"]["text"] + + +def test_create_response_body_update_body_with_reformatted_citations( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + # perform operation + response = _create_response_body( + citations=[ + { + "source_number": "1", + "title": "Citation Title", + "body": "Citation Body", + "relevance_score": "0.95", + } + ], + feedback_data={}, + response_text="This is a response with a citation.[cit_1]", + ) + + # assertions + assert len(response) > 1 + assert response[0]["type"] == "section" + assert "This is a response with a citation.[1]" in response[0]["text"]["text"] + + +def test_create_response_body_creates_body_with_markdown_formatting( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + # perform operation + response = _create_response_body( + citations=[ + { + "source_number": "1", + "title": "Citation Title", + "excerpt": "**Bold**, __italics__, and `code`.", + "relevance_score": "0.95", + } + ], + feedback_data={}, + response_text="This is a response with a citation.[1]", + ) + + # assertions + assert len(response) > 1 + assert response[1]["type"] == "actions" + assert response[1]["block_id"] == "citation_actions" + + citation_element = response[1]["elements"][0] + citation_value = json.loads(citation_element["value"]) + + assert "*Bold*, _italics_, and `code`." in citation_value.get("body") + + +def test_create_response_body_creates_body_with_lists( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + dirty_input = "Header text - Standard Dash -No Space Dash • Standard Bullet -NoSpace-NoSpace" + + # perform operation + response = _create_response_body( + citations=[ + { + "source_number": "1", + "title": "Citation Title", + "excerpt": dirty_input, + "relevance_score": "0.95", + } + ], + feedback_data={}, + response_text="This is a response with a citation.[1]", + ) + + # assertions + assert len(response) > 1 + assert response[1]["type"] == "actions" + assert response[1]["block_id"] == "citation_actions" + + citation_element = response[1]["elements"][0] + citation_value = json.loads(citation_element["value"]) + + expected_output = "Header text\n- Standard Dash\n- No Space Dash\n- Standard Bullet\n- NoSpace-NoSpace" + assert expected_output in citation_value.get("body") + + +def test_create_response_body_creates_body_without_encoding_errors( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + # perform operation + response = _create_response_body( + citations=[ + { + "source_number": "1", + "title": "Citation Title", + "excerpt": "» Tabbing Issue. ⢠Bullet point issue.", + "relevance_score": "0.95", + } + ], + feedback_data={}, + response_text="This is a response with a citation.[1]", + ) + + # assertions + assert len(response) > 1 + assert response[1]["type"] == "actions" + assert response[1]["block_id"] == "citation_actions" + + citation_element = response[1]["elements"][0] + citation_value = json.loads(citation_element["value"]) + + assert "Tabbing Issue.\n- Bullet point issue." in citation_value.get("body") + + +@patch("app.services.ai_processor.process_ai_query") +def test_create_citation_logs_citations( + mock_process_ai_query: Mock, + mock_logger, +): + with patch("app.core.config.get_logger", return_value=mock_logger): + # set up mocks + mock_client = Mock() + raw_citation = "1||This is the Title||This is the excerpt/ citation||0.99" + mock_process_ai_query.return_value = { + "text": "AI response" + "------" + f"{raw_citation}", + "session_id": "session-123", + "citations": [], + "kb_response": {"output": {"text": "AI response"}}, + } + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_slack_message + + # perform operation + slack_event_data = { + "text": "Answer", + "user": "U456", + "channel": "C789", + "ts": "1234567890.123", + } + + process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) + + # mock_logger.assert_has_calls([call.info("Found citation(s)", extra={"Raw Citations": [raw_citation]})]) + # assertions + + mock_logger.info.assert_any_call( + "Found citation(s)", extra={"Raw Citations": ["1||This is the Title||This is the excerpt/ citation||0.99"]} + ) + mock_logger.info.assert_any_call( + "Parsed citation(s)", + extra={ + "citations": [ + { + "source_number": "1", + "title": "This is the Title", + "excerpt": "This is the excerpt/ citation", + "relevance_score": "0.99", + } + ] + }, + ) + # mock_logger.info.assert_called_with("Found citation(s)", extra={"Raw Citations": [raw_citation]}) diff --git a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py new file mode 100644 index 00000000..cd180566 --- /dev/null +++ b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py @@ -0,0 +1,499 @@ +import sys +import pytest +from unittest.mock import Mock, patch, MagicMock + + +@pytest.fixture +def mock_logger(): + return MagicMock() + + +@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +def test_process_async_slack_event_normal_message( + mock_forward_event_to_pull_request_lambda: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test successful async event processing""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_event + + # perform operation + slack_event_data = {"text": "<@U123> test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} + with patch("app.slack.slack_events.process_feedback_event") as mock_process_feedback_event, patch( + "app.slack.slack_events.process_slack_message" + ) as mock_process_slack_message: + process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) + mock_forward_event_to_pull_request_lambda.assert_not_called() + mock_process_feedback_event.assert_not_called() + mock_process_slack_message.assert_called_once_with( + event=slack_event_data, event_id="evt123", client=mock_client + ) + + +@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +def test_process_async_slack_event_pull_request_with_mention( + mock_forward_event_to_pull_request_lambda: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test successful async event processing""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_event + + # perform operation + slack_event_data = { + "text": "<@U123> pr: 123 test question", + "user": "U456", + "channel": "C789", + "ts": "1234567890.123", + } + with patch("app.slack.slack_events.process_feedback_event") as mock_process_feedback_event, patch( + "app.slack.slack_events.process_slack_message" + ) as mock_process_slack_message: + process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) + mock_forward_event_to_pull_request_lambda.assert_called_once_with( + pull_request_id="123", + event=slack_event_data, + event_id="evt123", + store_pull_request_id=True, + ) + mock_process_feedback_event.assert_not_called() + mock_process_slack_message.assert_not_called() + + +@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +def test_process_async_slack_event_pull_request_with_no_mention( + mock_forward_event_to_pull_request_lambda: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test successful async event processing""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_event + + # perform operation + slack_event_data = { + "text": "pr: 123 test question", + "user": "U456", + "channel": "C789", + "ts": "1234567890.123", + } + with patch("app.slack.slack_events.process_feedback_event") as mock_process_feedback_event, patch( + "app.slack.slack_events.process_slack_message" + ) as mock_process_slack_message: + process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) + mock_forward_event_to_pull_request_lambda.assert_called_once_with( + pull_request_id="123", + event=slack_event_data, + event_id="evt123", + store_pull_request_id=True, + ) + mock_process_feedback_event.assert_not_called() + mock_process_slack_message.assert_not_called() + + +def test_process_slack_message_empty_query(mock_get_parameter: Mock, mock_env: Mock): + """Test async event processing with empty query""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_slack_message + + # perform operation + slack_event_data = { + "text": "<@U123>", # Only mention, no actual query + "user": "U456", + "channel": "C789", + "ts": "1234567890.123", + } + process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) + + # assertions + mock_client.chat_postMessage.assert_called_once_with( + channel="C789", + text="Hi there! Please ask me a question and I'll help you find information from our knowledge base.", + thread_ts="1234567890.123", + ) + + +@patch("app.services.dynamo.get_state_information") +@patch("app.services.ai_processor.process_ai_query") +@patch("app.slack.slack_events.get_conversation_session") +def test_process_slack_message_with_thread_ts( + mock_get_session: Mock, + mock_process_ai_query: Mock, + mock_get_state_information: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test async event processing with existing thread_ts""" + # set up mocks + mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} + mock_client.chat_update.return_value = {"ok": True} + mock_process_ai_query.return_value = { + "text": "AI response", + "session_id": "session-123", + "citations": [], + "kb_response": {"output": {"text": "AI response"}}, + } + mock_get_session.return_value = None # No existing session + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_slack_message + + # perform operation + slack_event_data = { + "text": "<@U123> test question", + "user": "U456", + "channel": "C789", + "ts": "1234567890.123", + "thread_ts": "1234567888.111", # Existing thread + } + process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) + + # assertions + # Should be called at least once with the correct thread_ts + assert mock_client.chat_postMessage.call_count >= 1 + first_call = mock_client.chat_postMessage.call_args_list[0] + assert first_call[1]["thread_ts"] == "1234567888.111" + assert first_call[1]["text"] == "AI response" + + +@patch("app.services.dynamo.get_state_information") +@patch("app.services.ai_processor.process_ai_query") +@patch("app.slack.slack_events.get_conversation_session") +def test_regex_text_processing( + mock_get_session: Mock, + mock_process_ai_query: Mock, + mock_get_state_information: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # set up mocks + mock_client = Mock() + mock_process_ai_query.return_value = { + "text": "AI response", + "session_id": "session-123", + "citations": [], + "kb_response": {"output": {"text": "AI response"}}, + } + mock_get_session.return_value = None # No existing session + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_slack_message + + # perform operation + slack_event_data = {"text": "<@U123456> test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} + + process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) + + # assertions + # Verify that the message was processed (process_ai_query was called) + mock_process_ai_query.assert_called_once() + # The actual regex processing happens inside the function + assert mock_client.chat_postMessage.called + + +@patch("app.services.dynamo.get_state_information") +@patch("app.services.dynamo.store_state_information") +@patch("app.services.ai_processor.process_ai_query") +def test_process_slack_message_with_session_storage( + mock_process_ai_query: Mock, + mock_store_state_information: Mock, + mock_get_state_information: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test async event processing that stores a new session""" + # set up mocks + mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} + mock_client.chat_update.return_value = {"ok": True} + mock_process_ai_query.return_value = { + "text": "AI response", + "session_id": "new-session-123", + "citations": [], + "kb_response": {"output": {"text": "AI response"}, "sessionId": "new-session-123"}, + } + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_slack_message + + # perform operation + slack_event_data = {"text": "test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} + + process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) + + # assertions + # Verify session was stored - should be called twice (Q&A pair + session) + assert mock_store_state_information.call_count >= 1 + + +@patch("app.services.dynamo.get_state_information") +@patch("app.services.ai_processor.process_ai_query") +@patch("app.slack.slack_events.get_conversation_session") +def test_process_slack_message_chat_update_no_error( + mock_get_session: Mock, + mock_process_ai_query: Mock, + mock_get_state_information: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test process_async_slack_event with chat_update error""" + # set up mocks + mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} + mock_client.chat_update.side_effect = Exception("Update failed") + mock_process_ai_query.return_value = { + "text": "AI response", + "session_id": "session-123", + "citations": [], + "kb_response": {"output": {"text": "AI response"}}, + } + mock_get_session.return_value = None # No existing session + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_slack_message + + # perform operation + slack_event_data = {"text": "<@U123> test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} + process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) + + # assertions + # no assertions as we are just checking it does not throw an error + + +@patch("app.slack.slack_events.get_conversation_session") +@patch("app.slack.slack_events.get_conversation_session_data") +@patch("app.slack.slack_events.cleanup_previous_unfeedback_qa") +@patch("app.slack.slack_events.update_session_latest_message") +@patch("app.services.ai_processor.process_ai_query") +def test_process_slack_message_chat_update_cleanup( + mock_process_ai_query: Mock, + mock_update_session_latest_message: Mock, + mock_cleanup_previous_unfeedback_qa: Mock, + mock_get_conversation_session_data: Mock, + mock_get_session: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test process_async_slack_event with chat_update error""" + # set up mocks + mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": "1234567890.124"} + mock_client.chat_update.side_effect = Exception("Update failed") + mock_process_ai_query.return_value = { + "text": "AI response", + "session_id": "session-123", + "citations": [], + "kb_response": {"output": {"text": "AI response"}}, + } + mock_get_conversation_session_data.return_value = {"session_id": "session-123"} + mock_get_session.return_value = None # No existing session + mock_cleanup_previous_unfeedback_qa.return_value = {"test": "123"} + + # delete and import module to test + from app.slack.slack_events import process_slack_message + + # perform operation + slack_event_data = {"text": "<@U123> test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} + with patch("app.slack.slack_events.get_conversation_session_data", mock_get_conversation_session_data): + process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) + + # assertions + mock_cleanup_previous_unfeedback_qa.assert_called_once() + mock_update_session_latest_message.assert_called_once() + + +@patch("app.services.dynamo.get_state_information") +@patch("app.services.ai_processor.process_ai_query") +@patch("app.slack.slack_events.get_conversation_session") +def test_process_slack_message_dm_context( + mock_get_session: Mock, + mock_process_ai_query: Mock, + mock_get_state_information: Mock, + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test process_async_slack_event with DM context""" + # set up mocks + mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": "123"} + mock_process_ai_query.return_value = { + "text": "AI response", + "session_id": "new-session", + "citations": [], + "kb_response": {"output": {"text": "AI response"}, "sessionId": "new-session"}, + } + mock_get_session.return_value = None + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_event + + # perform operation + slack_event_data = { + "text": "test question", + "user": "U456", + "channel": "D789", + "ts": "123", + "channel_type": "im", # DM context + } + process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) + + # assertions + # no assertions as we are just checking it does not throw an error + + +@patch("app.services.dynamo.delete_state_information") +def test_cleanup_previous_unfeedback_qa_no_previous_message( + mock_delete_state_information: Mock, +): + """Test cleanup skipped when no previous message exists""" + conversation_key = "conv-123" + current_message_ts = "1234567890.124" + session_data = {} + + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import cleanup_previous_unfeedback_qa + + # perform operation + cleanup_previous_unfeedback_qa(conversation_key, current_message_ts, session_data) + + # assertions + mock_delete_state_information.assert_not_called() + + +@patch("app.services.dynamo.delete_state_information") +def test_cleanup_previous_unfeedback_qa_same_message( + mock_delete_state_information: Mock, +): + """Test cleanup skipped when previous message is same as current""" + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + + conversation_key = "conv-123" + current_message_ts = "1234567890.123" + session_data = {"latest_message_ts": "1234567890.123"} + + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import cleanup_previous_unfeedback_qa + + # perform operation + cleanup_previous_unfeedback_qa(conversation_key, current_message_ts, session_data) + + # assertions + mock_delete_state_information.assert_not_called() + + +def test_create_response_body_creates_body_with_markdown_formatting( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + # perform operation + response = _create_response_body( + citations=[], + feedback_data={}, + response_text="**Bold**, __italics__, and `code`.", + ) + + # assertions + assert len(response) > 0 + assert response[0]["type"] == "section" + + response_value = response[0]["text"]["text"] + + assert "*Bold*, _italics_, and `code`." in response_value + + +def test_create_response_body_creates_body_with_lists( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + dirty_input = "Header text - Standard Dash -No Space Dash • Standard Bullet -NoSpace-NoSpace" + + # perform operation + response = _create_response_body( + citations=[], + feedback_data={}, + response_text=dirty_input, + ) + + # assertions + assert len(response) > 0 + assert response[0]["type"] == "section" + + response_value = response[0]["text"]["text"] + + expected_output = "Header text\n- Standard Dash\n- No Space Dash\n- Standard Bullet\n- NoSpace-NoSpace" + assert expected_output in response_value + + +def test_create_response_body_creates_body_without_encoding_errors( + mock_get_parameter: Mock, + mock_env: Mock, +): + """Test regex text processing functionality within process_async_slack_event""" + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import _create_response_body + + # perform operation + response = _create_response_body( + citations=[], + feedback_data={}, + response_text="» Tabbing Issue. ⢠Bullet point issue.", + ) + + # assertions + assert len(response) > 0 + assert response[0]["type"] == "section" + + response_value = response[0]["text"]["text"] + + assert "Tabbing Issue.\n- Bullet point issue." in response_value