Add sender context mode for text generation (#104)

Add a per-room/global `sender_context_mode` setting that optionally
prefixes conversation messages with sender metadata before sending them
to the model provider.

This helps models distinguish between participants in multi-user rooms.
                                                                                                                                                                                                                                                             
Three modes are supported:
- `disabled` (default, no change)
- `matrix_user_id` (prefixes with `[sender=@user:server]`)
- `matrix_user_id_and_timestamp` (adds `send_at`; Example: `[sender=@user:server sent_at=<ISO 8601>]`)
                                                                                                                                                                                                                                                             
Sender context is applied to user and assistant text messages only,
skipping system prompts and non-text content.

Mixed-sender merged turns (something we intentionally do for Anthropic)
have their `sender_id` cleared to avoid misattribution.
This commit is contained in:
kschwank
2026-03-25 19:14:34 +01:00
committed by GitHub
parent 661e7263fb
commit 2d659964a7
25 changed files with 719 additions and 14 deletions

View File

@@ -3,7 +3,8 @@ use crate::{
entity::roomconfig::{
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,
TextToSpeechUserMessagesFlowType,
},
};
@@ -48,6 +49,9 @@ pub enum ConfigTextGenerationSettingRelatedControllerType {
GetTemperatureOverride,
SetTemperatureOverride(Option<f32>),
GetSenderContextMode,
SetSenderContextMode(Option<TextGenerationSenderContextMode>),
}
#[derive(Debug, PartialEq)]

View File

@@ -163,6 +163,26 @@ fn determine_controller() {
),
)),
},
TestCase {
name: "per-room text-generation/sender-context-mode getter",
input: "room text-generation sender-context-mode",
expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(
controller_type::SettingsStorageSource::Room,
controller_type::ConfigSettingRelatedControllerType::TextGeneration(
controller_type::ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode,
),
)),
},
TestCase {
name: "global text-generation/sender-context-mode getter",
input: "global text-generation sender-context-mode",
expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(
controller_type::SettingsStorageSource::Global,
controller_type::ConfigSettingRelatedControllerType::TextGeneration(
controller_type::ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode,
),
)),
},
TestCase {
name: "per-room text-to-speech/speed-override getter",
input: "room text-to-speech speed-override",

View File

@@ -3,7 +3,10 @@ mod tests;
use crate::{
controller::ControllerType,
entity::roomconfig::{TextGenerationAutoUsage, TextGenerationPrefixRequirementType},
entity::roomconfig::{
TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
TextGenerationSenderContextMode,
},
strings,
};
@@ -197,5 +200,43 @@ pub(super) fn determine(
);
}
if let Some(remaining_text) = text.strip_prefix("sender-context-mode") {
let remaining_text = remaining_text.trim();
if !remaining_text.is_empty() {
return Err(ControllerType::Error(
strings::cfg::configuration_getter_used_with_extra_text(
"sender-context-mode",
remaining_text,
)
.to_owned(),
));
}
return Ok(ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode);
}
if let Some(value_string) = text.strip_prefix("set-sender-context-mode") {
let value_string = value_string.trim().to_owned();
let value_choice = if value_string.is_empty() {
None
} else {
let value_choice =
TextGenerationSenderContextMode::from_str(&value_string.to_lowercase());
if value_choice.is_none() {
return Err(ControllerType::Error(
strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),
));
}
value_choice
};
return Ok(
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(value_choice),
);
}
Err(ControllerType::Unknown)
}

View File

@@ -90,6 +90,74 @@ fn determine_controller_context_management() {
}
}
#[test]
fn determine_controller_sender_context() {
use super::ConfigTextGenerationSettingRelatedControllerType;
use super::ControllerType;
use crate::entity::roomconfig::TextGenerationSenderContextMode;
struct TestCase {
name: &'static str,
input: &'static str,
expected: Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType>,
}
let test_cases = vec![
TestCase {
name: "sender-context-mode getter ok",
input: "sender-context-mode",
expected: Ok(ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode),
},
TestCase {
name: "sender-context-mode getter extra args",
input: "sender-context-mode some values here",
expected: Err(ControllerType::Error(
crate::strings::cfg::configuration_getter_used_with_extra_text(
"sender-context-mode",
"some values here",
),
)),
},
TestCase {
name: "sender-context-mode setter matrix_user_id",
input: "set-sender-context-mode matrix_user_id",
expected: Ok(
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(Some(
TextGenerationSenderContextMode::MatrixUserId,
)),
),
},
TestCase {
name: "sender-context-mode setter uppercase",
input: "set-sender-context-mode MATRIX_USER_ID_AND_TIMESTAMP",
expected: Ok(
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(Some(
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
)),
),
},
TestCase {
name: "sender-context-mode setter invalid",
input: "set-sender-context-mode non-Enum-Value",
expected: Err(ControllerType::Error(
crate::strings::cfg::configuration_value_unrecognized("non-Enum-Value"),
)),
},
TestCase {
name: "sender-context-mode unsetter",
input: "set-sender-context-mode",
expected: Ok(
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(None),
),
},
];
for test_case in test_cases {
let result = super::determine(test_case.input);
assert_eq!(result, test_case.expected, "Test case: {}", test_case.name);
}
}
#[test]
fn determine_controller_prefix_requirement_type() {
use super::ConfigTextGenerationSettingRelatedControllerType;

View File

@@ -1,5 +1,6 @@
use crate::entity::roomconfig::{
RoomSettings, TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
TextGenerationSenderContextMode,
};
use crate::{Bot, entity::MessageContext};
@@ -151,5 +152,38 @@ pub(super) async fn dispatch(
}
}
}
ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode => {
let value = &room_settings.text_generation.sender_context_mode;
setting_get::<TextGenerationSenderContextMode>(bot, message_context, value).await
}
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(value) => {
let value = value.to_owned();
let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {
room_settings.text_generation.sender_context_mode = value;
});
match config_type {
SettingsStorageSource::Room => {
room_setting_set::<TextGenerationSenderContextMode>(
bot,
message_context,
&value,
setter_callback,
)
.await
}
SettingsStorageSource::Global => {
global_setting_set::<TextGenerationSenderContextMode>(
bot,
message_context,
&value,
setter_callback,
)
.await
}
}
}
}
}

View File

@@ -7,7 +7,8 @@ use crate::{
roomconfig::{
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,
TextToSpeechUserMessagesFlowType,
},
},
strings,
@@ -233,6 +234,46 @@ fn build_section_text_generation(command_prefix: &str, bot_username: &str) -> St
));
message.push_str("\n\n");
// Sender Context
message.push_str(&format!(
"#### {}",
strings::help::cfg::text_generation_sender_context_heading()
));
message.push_str("\n\n");
message.push_str(&strings::help::cfg::text_generation_sender_context_intro());
message.push('\n');
message.push_str(
&strings::help::cfg::the_following_configuration_values_are_recognized(
TextGenerationSenderContextMode::choices(),
),
);
message.push_str("\n\n");
message.push_str(&format!(
"- {}",
&strings::help::cfg::current_setting_show(
command_prefix,
"text-generation sender-context-mode"
)
));
message.push('\n');
message.push_str(&format!(
"- {}",
&strings::help::cfg::current_setting_set(
command_prefix,
"text-generation set-sender-context-mode VALUE"
)
));
message.push('\n');
message.push_str(&format!(
"- {}",
&strings::help::cfg::current_setting_unset(
command_prefix,
"text-generation set-sender-context-mode"
)
));
message.push_str("\n\n");
// Prompt override
message.push_str(&format!(

View File

@@ -359,6 +359,33 @@ async fn generate_text_generation_section(
),
);
// Sender Context
let effective_sender_context = room_config_context.text_generation_sender_context_mode();
let room_config_sender_context = room_config_context
.room_config
.settings
.text_generation
.sender_context_mode;
let global_config_sender_context = room_config_context
.global_config
.fallback_room_settings
.text_generation
.sender_context_mode;
let sender_context_set_where = if room_config_sender_context.is_some() {
strings::cfg::status_badge_set_in_room_config()
} else if global_config_sender_context.is_some() {
strings::cfg::status_badge_set_in_global_config()
} else {
strings::cfg::status_badge_using_hardcoded_default()
};
message.push_str(&strings::cfg::status_text_generation_entry_sender_context(
effective_sender_context,
sender_context_set_where,
));
// Prompt override
let text_agent_prompt = if let Some(text_generation_agent) = &text_generation_agent {