Skip to content

Commit 4f0a15d

Browse files
authored
Merge pull request #1079 from microsoft/feature/989-custom-content-safety-message
feat: configurable Content Safety violation messages
2 parents 8c993f4 + d84eb59 commit 4f0a15d

11 files changed

Lines changed: 382 additions & 30 deletions

File tree

‎application/external_apps/databaseseeder/artifacts/admin_settings.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@
9090
"audio_files_authentication_type": "key",
9191
"audio_files_key": "",
9292
"enable_content_safety": false,
93+
"content_safety_violation_message": "Your message was blocked by Content Safety.",
94+
"content_safety_include_trigger_information": true,
9395
"require_member_of_safety_violation_admin": false,
9496
"content_safety_endpoint": "",
9597
"content_safety_key": "",

‎application/single_app/config.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
EXECUTOR_TYPE = 'thread'
9898
EXECUTOR_MAX_WORKERS = 30
9999
SESSION_TYPE = 'filesystem'
100-
VERSION = "0.250.059"
100+
VERSION = "0.250.061"
101101
IS_DEVELOPMENT = is_development_env_enabled()
102102

103103
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# functions_content_safety.py
2+
"""Helpers for formatting user-visible Content Safety violation messages."""
3+
4+
CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT = 'Your message was blocked by Content Safety.'
5+
CONTENT_SAFETY_VIOLATION_MESSAGE_MAX_LENGTH = 3000
6+
7+
8+
def normalize_content_safety_violation_message(value):
9+
"""Return a non-empty, bounded Markdown message template."""
10+
candidate = str(value or '').replace('\r\n', '\n').replace('\r', '\n').strip()
11+
if not candidate:
12+
return CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT
13+
return candidate[:CONTENT_SAFETY_VIOLATION_MESSAGE_MAX_LENGTH]
14+
15+
16+
def build_content_safety_violation_message(
17+
settings,
18+
block_reasons,
19+
triggered_categories,
20+
blocklist_matches,
21+
):
22+
"""Build the Markdown message shown after a blocked chat request."""
23+
safety_settings = settings if isinstance(settings, dict) else {}
24+
message_template = normalize_content_safety_violation_message(
25+
safety_settings.get('content_safety_violation_message')
26+
)
27+
28+
if safety_settings.get('content_safety_include_trigger_information', True) is False:
29+
return message_template
30+
31+
normalized_reasons = [
32+
str(reason).strip()
33+
for reason in (block_reasons or [])
34+
if str(reason or '').strip()
35+
]
36+
trigger_lines = [
37+
f"**Reason**: {', '.join(normalized_reasons)}",
38+
'Triggered categories:',
39+
]
40+
41+
for category in triggered_categories or []:
42+
if not isinstance(category, dict):
43+
continue
44+
category_name = str(category.get('category') or '').strip()
45+
if category_name:
46+
trigger_lines.append(
47+
f" - {category_name} (severity={category.get('severity')})"
48+
)
49+
50+
formatted_blocklist_matches = []
51+
for match in blocklist_matches or []:
52+
if not isinstance(match, dict):
53+
continue
54+
item_text = str(match.get('blocklistItemText') or '').strip()
55+
blocklist_name = str(match.get('blocklistName') or '').strip()
56+
if item_text and blocklist_name:
57+
formatted_blocklist_matches.append(
58+
f" - {item_text} (in {blocklist_name})"
59+
)
60+
61+
if formatted_blocklist_matches:
62+
trigger_lines.extend(['', 'Blocklist Matches:', *formatted_blocklist_matches])
63+
64+
return f"{message_template}\n\n" + '\n'.join(trigger_lines)

‎application/single_app/functions_settings.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
from config import *
88
from functions_appinsights import log_event
9+
from functions_content_safety import (
10+
CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT,
11+
)
912
from functions_cosmos_throughput import get_default_cosmos_throughput_settings
1013
from functions_document_actions import get_default_document_action_capabilities
1114
from functions_icon_utils import normalize_icon_payload
@@ -1063,6 +1066,8 @@ def get_settings(use_cosmos=False, include_source=False):
10631066

10641067
# Safety (Content Safety) Settings
10651068
'enable_content_safety': False,
1069+
'content_safety_violation_message': CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT,
1070+
'content_safety_include_trigger_information': True,
10661071
'require_member_of_safety_violation_admin': False,
10671072
'require_member_of_control_center_admin': False,
10681073
'require_member_of_control_center_dashboard_reader': False,

‎application/single_app/route_backend_chats.py‎

Lines changed: 11 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
SEMANTIC_SEARCH_QUOTA_WARNING_TYPE,
6060
SemanticSearchQuotaExceededError,
6161
)
62+
from functions_content_safety import build_content_safety_violation_message
6263
from functions_settings import *
6364
from functions_assigned_knowledge import (
6465
ASSIGNED_KNOWLEDGE_USER_ACTION_ANALYZE,
@@ -2707,7 +2708,6 @@ def _build_safety_message_doc(
27072708
},
27082709
})
27092710

2710-
27112711
def _build_fact_memory_context_lines(
27122712
scope_id,
27132713
scope_type,
@@ -13410,21 +13410,12 @@ def result_requires_message_reload(result: Any) -> bool:
1341013410
cosmos_safety_container.upsert_item(safety_item)
1341113411

1341213412
# Instead of 403, we'll add a "safety" message
13413-
blocked_msg_content = (
13414-
"Your message was blocked by Content Safety.\n\n"
13415-
f"**Reason**: {', '.join(block_reasons)}\n"
13416-
"Triggered categories:\n"
13413+
blocked_msg_content = build_content_safety_violation_message(
13414+
settings=settings,
13415+
block_reasons=block_reasons,
13416+
triggered_categories=triggered_categories,
13417+
blocklist_matches=blocklist_matches,
1341713418
)
13418-
for cat in triggered_categories:
13419-
blocked_msg_content += (
13420-
f" - {cat['category']} (severity={cat['severity']})\n"
13421-
)
13422-
if blocklist_matches:
13423-
blocked_msg_content += (
13424-
"\nBlocklist Matches:\n" +
13425-
"\n".join([f" - {m['blocklistItemText']} (in {m['blocklistName']})"
13426-
for m in blocklist_matches])
13427-
)
1342813419

1342913420
# Insert a special "role": "safety" or "blocked"
1343013421
safety_doc = _build_safety_message_doc(
@@ -17039,21 +17030,12 @@ def record_and_publish_streaming_thought(thought_payload):
1703917030
cosmos_safety_container.upsert_item(safety_item)
1704017031

1704117032
# Build blocked message
17042-
blocked_msg_content = (
17043-
"Your message was blocked by Content Safety.\n\n"
17044-
f"**Reason**: {', '.join(block_reasons)}\n"
17045-
"Triggered categories:\n"
17033+
blocked_msg_content = build_content_safety_violation_message(
17034+
settings=settings,
17035+
block_reasons=block_reasons,
17036+
triggered_categories=triggered_categories,
17037+
blocklist_matches=blocklist_matches,
1704617038
)
17047-
for cat in triggered_categories:
17048-
blocked_msg_content += (
17049-
f" - {cat['category']} (severity={cat['severity']})\n"
17050-
)
17051-
if blocklist_matches:
17052-
blocked_msg_content += (
17053-
"\nBlocklist Matches:\n" +
17054-
"\n".join([f" - {m['blocklistItemText']} (in {m['blocklistName']})"
17055-
for m in blocklist_matches])
17056-
)
1705717039

1705817040
# Insert safety message
1705917041
safety_doc = _build_safety_message_doc(

‎application/single_app/route_frontend_admin_settings.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from functions_keyvault import keyvault_model_endpoint_cleanup_helper, keyvault_model_endpoint_delete_helper, keyvault_model_endpoint_save_helper, redact_model_endpoint_secret_values
1111
from functions_settings import *
12+
from functions_content_safety import normalize_content_safety_violation_message
1213
from functions_file_sync import FILE_SYNC_DEFAULTS, get_file_sync_config
1314
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
1415
from functions_control_center import (
@@ -126,6 +127,7 @@ def normalize_agents_page_text(value, fallback, max_length):
126127
candidate = fallback
127128
return candidate[:max_length]
128129

130+
129131
def register_route_frontend_admin_settings(bp):
130132
@bp.route('/admin/settings', methods=['GET', 'POST'])
131133
@swagger_route(security=get_auth_security())
@@ -720,6 +722,12 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul
720722
file_download_allowed_public_workspace_ids = normalize_file_download_allowed_public_workspace_ids(
721723
form_data.get('file_download_allowed_public_workspace_ids', '')
722724
)
725+
content_safety_violation_message = normalize_content_safety_violation_message(
726+
form_data.get('content_safety_violation_message')
727+
)
728+
content_safety_include_trigger_information = form_data.get(
729+
'content_safety_include_trigger_information'
730+
) == 'on'
723731
require_member_of_safety_violation_admin = form_data.get('require_member_of_safety_violation_admin') == 'on'
724732
require_member_of_control_center_admin = form_data.get('require_member_of_control_center_admin') == 'on'
725733
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):
21052113

21062114
# Safety (Content Safety Direct & APIM)
21072115
'enable_content_safety': form_data.get('enable_content_safety') == 'on',
2116+
'content_safety_violation_message': content_safety_violation_message,
2117+
'content_safety_include_trigger_information': content_safety_include_trigger_information,
21082118
'content_safety_endpoint': form_data.get('content_safety_endpoint', '').strip(),
21092119
'content_safety_key': admin_secret('content_safety_key'),
21102120
'content_safety_authentication_type': form_data.get('content_safety_authentication_type', 'key'),

‎application/single_app/templates/admin_settings.html‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9062,6 +9062,37 @@ <h5>
90629062
</div>
90639063
</div>
90649064

9065+
9066+
<div class="mb-3">
9067+
<label for="content_safety_violation_message" class="form-label">
9068+
Safety Violation Message <span class="text-muted fw-normal">(Markdown supported)</span>
9069+
</label>
9070+
<textarea
9071+
class="form-control"
9072+
id="content_safety_violation_message"
9073+
name="content_safety_violation_message"
9074+
rows="4"
9075+
maxlength="3000"
9076+
placeholder="Your message was blocked by Content Safety."
9077+
>{{ settings.content_safety_violation_message or '' }}</textarea>
9078+
<div class="form-text">Displayed when Content Safety blocks a chat message.</div>
9079+
</div>
9080+
9081+
<div class="form-group form-check form-switch mb-2">
9082+
<input
9083+
type="checkbox"
9084+
class="form-check-input"
9085+
id="content_safety_include_trigger_information"
9086+
name="content_safety_include_trigger_information"
9087+
{% if settings.content_safety_include_trigger_information %}checked{% endif %}
9088+
>
9089+
<label class="form-check-label ms-2" for="content_safety_include_trigger_information">
9090+
Include Trigger Information
9091+
</label>
9092+
<i class="bi bi-info-circle ms-2" data-bs-toggle="tooltip" title="Show the reason, detected categories and severities, and blocklist matches below the safety message."></i>
9093+
</div>
9094+
<p class="form-text text-muted mb-3">Disable this option to show only the configured message.</p>
9095+
90659096
<button type="button" class="btn btn-secondary mt-3" id="test_safety_button">
90669097
Test Safety Connection
90679098
</button>
@@ -11129,6 +11160,7 @@ <h5 class="modal-title" id="userAgreementPreviewModalLabel">
1112911160
// Existing SimpleMDE script...
1113011161
// We’ll store the SimpleMDE instance globally so we can destroy or read its content
1113111162
let simplemde = null;
11163+
let contentSafetyViolationMessageEditor = null;
1113211164

1113311165
// Grab references to the toggle, the editor container, and the preview container
1113411166
const toggleLandingPageEditor = document.getElementById("toggleLandingPageEditor");
@@ -11186,6 +11218,56 @@ <h5 class="modal-title" id="userAgreementPreviewModalLabel">
1118611218
showPreview();
1118711219
}
1118811220
}
11221+
11222+
function initializeContentSafetyViolationMessageEditor() {
11223+
if (contentSafetyViolationMessageEditor || typeof SimpleMDE === "undefined") {
11224+
return;
11225+
}
11226+
11227+
const contentSafetyViolationMessageInput = document.getElementById(
11228+
"content_safety_violation_message"
11229+
);
11230+
if (!contentSafetyViolationMessageInput) {
11231+
return;
11232+
}
11233+
11234+
try {
11235+
contentSafetyViolationMessageEditor = new SimpleMDE({
11236+
element: contentSafetyViolationMessageInput,
11237+
spellChecker: false,
11238+
autoDownloadFontAwesome: false,
11239+
forceSync: true
11240+
});
11241+
contentSafetyViolationMessageEditor.codemirror.on("change", () => {
11242+
if (typeof window.markFormAsModified === "function") {
11243+
window.markFormAsModified();
11244+
}
11245+
});
11246+
} catch (error) {
11247+
console.error("Failed to initialize SimpleMDE for Content Safety violation messages:", error);
11248+
}
11249+
}
11250+
11251+
function refreshContentSafetyViolationMessageEditor() {
11252+
if (!contentSafetyViolationMessageEditor?.codemirror) {
11253+
return;
11254+
}
11255+
11256+
window.setTimeout(() => {
11257+
contentSafetyViolationMessageEditor.codemirror.refresh();
11258+
}, 100);
11259+
}
11260+
11261+
initializeContentSafetyViolationMessageEditor();
11262+
11263+
const safetyTab = document.getElementById("safety-tab");
11264+
if (safetyTab) {
11265+
safetyTab.addEventListener("shown.bs.tab", refreshContentSafetyViolationMessageEditor);
11266+
}
11267+
if (document.getElementById("safety")?.classList.contains("show")) {
11268+
refreshContentSafetyViolationMessageEditor();
11269+
}
11270+
window.addEventListener("load", refreshContentSafetyViolationMessageEditor);
1118911271
</script>
1119011272

1119111273
<script>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Content Safety Violation Messages
2+
3+
Implemented in version: **0.250.061**
4+
5+
Related config.py version update: `application/single_app/config.py` is **0.250.061** for this implementation.
6+
7+
## Overview
8+
9+
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.
10+
11+
## Dependencies
12+
13+
- Content Safety must be enabled in Admin Settings.
14+
- The existing local `marked` and `DOMPurify` assets render and sanitize the saved Markdown in chat.
15+
16+
## Technical Specifications
17+
18+
- `content_safety_violation_message` stores the administrator-authored Markdown template.
19+
- `content_safety_include_trigger_information` controls whether Content Safety details are appended to the message.
20+
- The default message is `Your message was blocked by Content Safety.` and trigger information is enabled by default, preserving previous behavior.
21+
- Blank or missing templates fall back to the default message, and saved templates are limited to 3,000 characters.
22+
- Normal and streaming chat requests call the same backend formatter before persisting and returning a `safety` message.
23+
- The chat UI renders safety messages through `DOMPurify.sanitize(marked.parse(...))`; no remote browser assets are added.
24+
- The editor refreshes after the hidden Safety tab becomes visible, and Markdown-only edits enable Save Settings and synchronize to the submitted form value.
25+
26+
## Usage Instructions
27+
28+
1. Open **Admin Settings** and enable Content Safety.
29+
2. Use the standard Markdown toolbar in **Safety Violation Message** to format the desired text.
30+
3. Keep **Include Trigger Information** selected to append the block reason, categories, severities, and blocklist matches. Clear it to show only the configured message.
31+
4. Save the settings. Newly blocked chat messages use the saved configuration.
32+
33+
## Testing and Validation
34+
35+
- `functional_tests/test_content_safety_violation_message.py` validates the Markdown editor toolbar, default output, Markdown template preservation, trigger-information suppression, and blank-template fallback.
36+
- Admin Settings validation verifies message normalization, seed defaults, and the form controls.
37+
- Version was updated in `application/single_app/config.py` to `0.250.061` for traceability.
38+
39+
## Known Limitations
40+
41+
- The setting applies to text-chat Content Safety blocks. Image-generation moderation messages continue to use their existing dedicated copy.
42+
- Saved messages affect newly created safety messages; historical conversation messages retain the text stored when they were created.

‎docs/explanation/features/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ category: Version History
1717
- [AI Voice Conversations Setup Guide](AI_VOICE_CONVERSATIONS_SETUP_GUIDE.md)
1818
- [Activity Log Auto-Refresh](CONTROL_CENTER_ACTIVITY_LOG_AUTO_REFRESH.md)
1919
- [Activity Log Layout Presets](ACTIVITY_LOG_LAYOUT_PRESETS.md)
20+
- [Content Safety Violation Messages](CONTENT_SAFETY_VIOLATION_MESSAGES.md)
2021

2122
## Agent and Action Features
2223

‎docs/explanation/release_notes.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/).
44

5+
### **(v0.250.061)**
6+
7+
#### New Features
8+
9+
* **Configurable Content Safety Violation Messages**
10+
* Administrators can now configure the Markdown message shown when Content Safety blocks a chat request using the standard Markdown editor toolbar.
11+
* A new setting controls whether the block reason, detected categories and severities, and blocklist matches are included beneath the custom message.
12+
* The editor now renders correctly when the hidden Safety tab opens, and Markdown-only edits activate Save Settings before submission.
13+
* (Ref: microsoft/simplechat#989, `functions_content_safety.py`, `admin_settings.html`, `route_backend_chats.py`)
14+
515
### **(v0.250.059)**
616

717
#### New Features

0 commit comments

Comments
 (0)