From 9736ab8b5dfa87451d6fdd086f7fb86112c32d7a Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 27 Jul 2026 14:36:47 -0400 Subject: [PATCH 1/3] feat: configure content safety violation messages (#989) --- .../artifacts/admin_settings.json | 2 + application/single_app/config.py | 2 +- .../single_app/functions_content_safety.py | 64 +++++++++ application/single_app/functions_settings.py | 5 + application/single_app/route_backend_chats.py | 40 ++---- .../route_frontend_admin_settings.py | 10 ++ .../single_app/templates/admin_settings.html | 31 +++++ .../CONTENT_SAFETY_VIOLATION_MESSAGES.md | 41 ++++++ docs/explanation/features/index.md | 1 + .../test_content_safety_violation_message.py | 127 ++++++++++++++++++ 10 files changed, 293 insertions(+), 30 deletions(-) create mode 100644 application/single_app/functions_content_safety.py create mode 100644 docs/explanation/features/CONTENT_SAFETY_VIOLATION_MESSAGES.md create mode 100644 functional_tests/test_content_safety_violation_message.py diff --git a/application/external_apps/databaseseeder/artifacts/admin_settings.json b/application/external_apps/databaseseeder/artifacts/admin_settings.json index 24c1c7ea4..2bbd3c5a1 100644 --- a/application/external_apps/databaseseeder/artifacts/admin_settings.json +++ b/application/external_apps/databaseseeder/artifacts/admin_settings.json @@ -90,6 +90,8 @@ "audio_files_authentication_type": "key", "audio_files_key": "", "enable_content_safety": false, + "content_safety_violation_message": "Your message was blocked by Content Safety.", + "content_safety_include_trigger_information": true, "require_member_of_safety_violation_admin": false, "content_safety_endpoint": "", "content_safety_key": "", diff --git a/application/single_app/config.py b/application/single_app/config.py index 4c9b91a3e..05675c43b 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.059" +VERSION = "0.250.060" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_content_safety.py b/application/single_app/functions_content_safety.py new file mode 100644 index 000000000..e44729fdf --- /dev/null +++ b/application/single_app/functions_content_safety.py @@ -0,0 +1,64 @@ +# functions_content_safety.py +"""Helpers for formatting user-visible Content Safety violation messages.""" + +CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT = 'Your message was blocked by Content Safety.' +CONTENT_SAFETY_VIOLATION_MESSAGE_MAX_LENGTH = 3000 + + +def normalize_content_safety_violation_message(value): + """Return a non-empty, bounded Markdown message template.""" + candidate = str(value or '').replace('\r\n', '\n').replace('\r', '\n').strip() + if not candidate: + return CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT + return candidate[:CONTENT_SAFETY_VIOLATION_MESSAGE_MAX_LENGTH] + + +def build_content_safety_violation_message( + settings, + block_reasons, + triggered_categories, + blocklist_matches, +): + """Build the Markdown message shown after a blocked chat request.""" + safety_settings = settings if isinstance(settings, dict) else {} + message_template = normalize_content_safety_violation_message( + safety_settings.get('content_safety_violation_message') + ) + + if safety_settings.get('content_safety_include_trigger_information', True) is False: + return message_template + + normalized_reasons = [ + str(reason).strip() + for reason in (block_reasons or []) + if str(reason or '').strip() + ] + trigger_lines = [ + f"**Reason**: {', '.join(normalized_reasons)}", + 'Triggered categories:', + ] + + for category in triggered_categories or []: + if not isinstance(category, dict): + continue + category_name = str(category.get('category') or '').strip() + if category_name: + trigger_lines.append( + f" - {category_name} (severity={category.get('severity')})" + ) + + formatted_blocklist_matches = [] + for match in blocklist_matches or []: + if not isinstance(match, dict): + continue + item_text = str(match.get('blocklistItemText') or '').strip() + blocklist_name = str(match.get('blocklistName') or '').strip() + if item_text and blocklist_name: + formatted_blocklist_matches.append( + f" - {item_text} (in {blocklist_name})" + ) + + if formatted_blocklist_matches: + trigger_lines.extend(['', 'Blocklist Matches:', *formatted_blocklist_matches]) + + return f"{message_template}\n\n" + '\n'.join(trigger_lines) diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index d54d3824f..9acbf63a9 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -6,6 +6,9 @@ from config import * from functions_appinsights import log_event +from functions_content_safety import ( + CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT, +) from functions_cosmos_throughput import get_default_cosmos_throughput_settings from functions_document_actions import get_default_document_action_capabilities from functions_icon_utils import normalize_icon_payload @@ -1063,6 +1066,8 @@ def get_settings(use_cosmos=False, include_source=False): # Safety (Content Safety) Settings 'enable_content_safety': False, + 'content_safety_violation_message': CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT, + 'content_safety_include_trigger_information': True, 'require_member_of_safety_violation_admin': False, 'require_member_of_control_center_admin': False, 'require_member_of_control_center_dashboard_reader': False, diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index e73a05c03..b7db677e4 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -59,6 +59,7 @@ SEMANTIC_SEARCH_QUOTA_WARNING_TYPE, SemanticSearchQuotaExceededError, ) +from functions_content_safety import build_content_safety_violation_message from functions_settings import * from functions_assigned_knowledge import ( ASSIGNED_KNOWLEDGE_USER_ACTION_ANALYZE, @@ -2707,7 +2708,6 @@ def _build_safety_message_doc( }, }) - def _build_fact_memory_context_lines( scope_id, scope_type, @@ -13410,21 +13410,12 @@ def result_requires_message_reload(result: Any) -> bool: cosmos_safety_container.upsert_item(safety_item) # Instead of 403, we'll add a "safety" message - blocked_msg_content = ( - "Your message was blocked by Content Safety.\n\n" - f"**Reason**: {', '.join(block_reasons)}\n" - "Triggered categories:\n" + blocked_msg_content = build_content_safety_violation_message( + settings=settings, + block_reasons=block_reasons, + triggered_categories=triggered_categories, + blocklist_matches=blocklist_matches, ) - for cat in triggered_categories: - blocked_msg_content += ( - f" - {cat['category']} (severity={cat['severity']})\n" - ) - if blocklist_matches: - blocked_msg_content += ( - "\nBlocklist Matches:\n" + - "\n".join([f" - {m['blocklistItemText']} (in {m['blocklistName']})" - for m in blocklist_matches]) - ) # Insert a special "role": "safety" or "blocked" safety_doc = _build_safety_message_doc( @@ -17039,21 +17030,12 @@ def record_and_publish_streaming_thought(thought_payload): cosmos_safety_container.upsert_item(safety_item) # Build blocked message - blocked_msg_content = ( - "Your message was blocked by Content Safety.\n\n" - f"**Reason**: {', '.join(block_reasons)}\n" - "Triggered categories:\n" + blocked_msg_content = build_content_safety_violation_message( + settings=settings, + block_reasons=block_reasons, + triggered_categories=triggered_categories, + blocklist_matches=blocklist_matches, ) - for cat in triggered_categories: - blocked_msg_content += ( - f" - {cat['category']} (severity={cat['severity']})\n" - ) - if blocklist_matches: - blocked_msg_content += ( - "\nBlocklist Matches:\n" + - "\n".join([f" - {m['blocklistItemText']} (in {m['blocklistName']})" - for m in blocklist_matches]) - ) # Insert safety message safety_doc = _build_safety_message_doc( diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 897951520..878c2abd8 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -9,6 +9,7 @@ from functions_keyvault import keyvault_model_endpoint_cleanup_helper, keyvault_model_endpoint_delete_helper, keyvault_model_endpoint_save_helper, redact_model_endpoint_secret_values from functions_settings import * +from functions_content_safety import normalize_content_safety_violation_message from functions_file_sync import FILE_SYNC_DEFAULTS, get_file_sync_config from functions_source_review import SOURCE_REVIEW_DEFAULTS, get_source_review_config, get_source_review_runtime_capabilities, normalize_source_review_js_rendering_enabled, parse_source_review_list from functions_control_center import ( @@ -126,6 +127,7 @@ def normalize_agents_page_text(value, fallback, max_length): candidate = fallback return candidate[:max_length] + def register_route_frontend_admin_settings(bp): @bp.route('/admin/settings', methods=['GET', 'POST']) @swagger_route(security=get_auth_security()) @@ -720,6 +722,12 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul file_download_allowed_public_workspace_ids = normalize_file_download_allowed_public_workspace_ids( form_data.get('file_download_allowed_public_workspace_ids', '') ) + content_safety_violation_message = normalize_content_safety_violation_message( + form_data.get('content_safety_violation_message') + ) + content_safety_include_trigger_information = form_data.get( + 'content_safety_include_trigger_information' + ) == 'on' require_member_of_safety_violation_admin = form_data.get('require_member_of_safety_violation_admin') == 'on' require_member_of_control_center_admin = form_data.get('require_member_of_control_center_admin') == 'on' require_member_of_control_center_dashboard_reader = form_data.get('require_member_of_control_center_dashboard_reader') == 'on' @@ -2105,6 +2113,8 @@ def is_valid_url(url): # Safety (Content Safety Direct & APIM) 'enable_content_safety': form_data.get('enable_content_safety') == 'on', + 'content_safety_violation_message': content_safety_violation_message, + 'content_safety_include_trigger_information': content_safety_include_trigger_information, 'content_safety_endpoint': form_data.get('content_safety_endpoint', '').strip(), 'content_safety_key': admin_secret('content_safety_key'), 'content_safety_authentication_type': form_data.get('content_safety_authentication_type', 'key'), diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index e9b758218..ec2514eeb 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -9062,6 +9062,37 @@
+ +
+ + +
Displayed when Content Safety blocks a chat message.
+
+ +
+ + + +
+

Disable this option to show only the configured message.

+ diff --git a/docs/explanation/features/CONTENT_SAFETY_VIOLATION_MESSAGES.md b/docs/explanation/features/CONTENT_SAFETY_VIOLATION_MESSAGES.md new file mode 100644 index 000000000..dee3a2cb4 --- /dev/null +++ b/docs/explanation/features/CONTENT_SAFETY_VIOLATION_MESSAGES.md @@ -0,0 +1,41 @@ +# Content Safety Violation Messages + +Implemented in version: **0.250.060** + +Related config.py version update: `application/single_app/config.py` is **0.250.060** for this implementation. + +## Overview + +Administrators can configure the Markdown message shown when Azure Content Safety blocks a chat message. They can also choose whether the response includes trigger information such as block reasons, detected categories with severity, and blocklist matches. + +## Dependencies + +- Content Safety must be enabled in Admin Settings. +- The existing local `marked` and `DOMPurify` assets render and sanitize the saved Markdown in chat. + +## Technical Specifications + +- `content_safety_violation_message` stores the administrator-authored Markdown template. +- `content_safety_include_trigger_information` controls whether Content Safety details are appended to the message. +- The default message is `Your message was blocked by Content Safety.` and trigger information is enabled by default, preserving previous behavior. +- Blank or missing templates fall back to the default message, and saved templates are limited to 3,000 characters. +- Normal and streaming chat requests call the same backend formatter before persisting and returning a `safety` message. +- The chat UI renders safety messages through `DOMPurify.sanitize(marked.parse(...))`; no remote browser assets are added. + +## Usage Instructions + +1. Open **Admin Settings** and enable Content Safety. +2. Enter the desired Markdown in **Safety Violation Message**. +3. Keep **Include Trigger Information** selected to append the block reason, categories, severities, and blocklist matches. Clear it to show only the configured message. +4. Save the settings. Newly blocked chat messages use the saved configuration. + +## Testing and Validation + +- `functional_tests/test_content_safety_violation_message.py` validates default output, Markdown template preservation, trigger-information suppression, and blank-template fallback. +- Admin Settings validation verifies message normalization, seed defaults, and the form controls. +- Version was updated in `application/single_app/config.py` to `0.250.060` for traceability. + +## Known Limitations + +- The setting applies to text-chat Content Safety blocks. Image-generation moderation messages continue to use their existing dedicated copy. +- Saved messages affect newly created safety messages; historical conversation messages retain the text stored when they were created. diff --git a/docs/explanation/features/index.md b/docs/explanation/features/index.md index 4fa5d6a7a..909bd5884 100644 --- a/docs/explanation/features/index.md +++ b/docs/explanation/features/index.md @@ -17,6 +17,7 @@ category: Version History - [AI Voice Conversations Setup Guide](AI_VOICE_CONVERSATIONS_SETUP_GUIDE.md) - [Activity Log Auto-Refresh](CONTROL_CENTER_ACTIVITY_LOG_AUTO_REFRESH.md) - [Activity Log Layout Presets](ACTIVITY_LOG_LAYOUT_PRESETS.md) +- [Content Safety Violation Messages](CONTENT_SAFETY_VIOLATION_MESSAGES.md) ## Agent and Action Features diff --git a/functional_tests/test_content_safety_violation_message.py b/functional_tests/test_content_safety_violation_message.py new file mode 100644 index 000000000..e8b5f57b7 --- /dev/null +++ b/functional_tests/test_content_safety_violation_message.py @@ -0,0 +1,127 @@ +# test_content_safety_violation_message.py +#!/usr/bin/env python3 +""" +Functional test for configurable Content Safety violation messages. +Version: 0.250.060 +Implemented in: 0.250.060 + +This test ensures that administrators can provide a Markdown safety message +and choose whether blocked-chat trigger information is included. +""" + +import os +import sys + + +APPLICATION_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '..', + 'application', + 'single_app', +) +sys.path.insert(0, APPLICATION_PATH) + +from functions_content_safety import ( + CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT, + CONTENT_SAFETY_VIOLATION_MESSAGE_MAX_LENGTH, + build_content_safety_violation_message, + normalize_content_safety_violation_message, +) + + +BLOCK_REASONS = ['Max severity >= 4', 'Blocklist match'] +TRIGGERED_CATEGORIES = [{'category': 'Hate', 'severity': 6}] +BLOCKLIST_MATCHES = [ + { + 'blocklistItemText': 'restricted phrase', + 'blocklistName': 'corporate-policy', + } +] + + +def test_message_normalization_preserves_safe_admin_defaults(): + """Blank, Windows-style, and oversized form values are normalized safely.""" + assert ( + normalize_content_safety_violation_message(None) + == CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT + ) + assert normalize_content_safety_violation_message( + ' **Blocked**\r\n\r\nReview the policy. ' + ) == '**Blocked**\n\nReview the policy.' + assert len( + normalize_content_safety_violation_message( + 'x' * (CONTENT_SAFETY_VIOLATION_MESSAGE_MAX_LENGTH + 1) + ) + ) == CONTENT_SAFETY_VIOLATION_MESSAGE_MAX_LENGTH + + +def test_default_message_preserves_trigger_information(): + """The default settings retain the legacy detailed safety response.""" + message = build_content_safety_violation_message( + {}, + BLOCK_REASONS, + TRIGGERED_CATEGORIES, + BLOCKLIST_MATCHES, + ) + + assert message == ( + 'Your message was blocked by Content Safety.\n\n' + '**Reason**: Max severity >= 4, Blocklist match\n' + 'Triggered categories:\n' + ' - Hate (severity=6)\n\n' + 'Blocklist Matches:\n' + ' - restricted phrase (in corporate-policy)' + ) + + +def test_custom_markdown_message_precedes_trigger_information(): + """A saved Markdown template remains intact before the trigger details.""" + markdown_template = ( + '**Message blocked**\n\n' + 'Review the [acceptable-use policy](/acceptable_use_policy).' + ) + message = build_content_safety_violation_message( + { + 'content_safety_violation_message': markdown_template, + 'content_safety_include_trigger_information': True, + }, + BLOCK_REASONS, + TRIGGERED_CATEGORIES, + BLOCKLIST_MATCHES, + ) + + assert message.startswith(markdown_template + '\n\n**Reason**:') + assert 'Blocklist Matches:' in message + + +def test_trigger_information_can_be_hidden_and_blank_templates_fall_back(): + """Admins can hide details, while blank templates use the default message.""" + hidden_details_message = build_content_safety_violation_message( + { + 'content_safety_violation_message': 'Please review the acceptable-use policy.', + 'content_safety_include_trigger_information': False, + }, + BLOCK_REASONS, + TRIGGERED_CATEGORIES, + BLOCKLIST_MATCHES, + ) + blank_template_message = build_content_safety_violation_message( + { + 'content_safety_violation_message': ' ', + 'content_safety_include_trigger_information': False, + }, + BLOCK_REASONS, + TRIGGERED_CATEGORIES, + BLOCKLIST_MATCHES, + ) + + assert hidden_details_message == 'Please review the acceptable-use policy.' + assert blank_template_message == 'Your message was blocked by Content Safety.' + + +if __name__ == '__main__': + test_message_normalization_preserves_safe_admin_defaults() + test_default_message_preserves_trigger_information() + test_custom_markdown_message_precedes_trigger_information() + test_trigger_information_can_be_hidden_and_blank_templates_fall_back() + print('Content Safety violation message tests passed.') From f9e8cde3e95763ed79622698841c38cb3ad52186 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 27 Jul 2026 14:43:14 -0400 Subject: [PATCH 2/3] feat: add safety message markdown toolbar (#989) --- .../single_app/templates/admin_settings.html | 26 +++++++++++++++++++ .../CONTENT_SAFETY_VIOLATION_MESSAGES.md | 4 +-- docs/explanation/release_notes.md | 9 +++++++ .../test_content_safety_violation_message.py | 18 +++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index ec2514eeb..912121cd2 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -11160,6 +11160,7 @@