diff --git a/src/OpenClaw.Shared/NotificationCategorizer.cs b/src/OpenClaw.Shared/NotificationCategorizer.cs
index e832638..b63e2b8 100644
--- a/src/OpenClaw.Shared/NotificationCategorizer.cs
+++ b/src/OpenClaw.Shared/NotificationCategorizer.cs
@@ -51,16 +51,22 @@ public class NotificationCategorizer
///
/// Classify a notification using the layered pipeline.
+ /// When is true (default),
+ /// structured metadata (Intent, Channel) is checked first.
+ /// When false, classification starts from user-defined rules then keyword fallback.
///
- public (string title, string type) Classify(OpenClawNotification notification, IReadOnlyList? userRules = null)
+ public (string title, string type) Classify(OpenClawNotification notification, IReadOnlyList? userRules = null, bool preferStructuredCategories = true)
{
- // 1. Structured metadata: Intent
- if (!string.IsNullOrEmpty(notification.Intent) && IntentMap.TryGetValue(notification.Intent, out var intentResult))
- return intentResult;
+ if (preferStructuredCategories)
+ {
+ // 1. Structured metadata: Intent
+ if (!string.IsNullOrEmpty(notification.Intent) && IntentMap.TryGetValue(notification.Intent, out var intentResult))
+ return intentResult;
- // 2. Structured metadata: Channel
- if (!string.IsNullOrEmpty(notification.Channel) && ChannelMap.TryGetValue(notification.Channel, out var channelResult))
- return channelResult;
+ // 2. Structured metadata: Channel
+ if (!string.IsNullOrEmpty(notification.Channel) && ChannelMap.TryGetValue(notification.Channel, out var channelResult))
+ return channelResult;
+ }
// 3. User-defined rules (pattern match on title + message)
if (userRules is { Count: > 0 })
diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
index f47a984..ebe8e34 100644
--- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs
+++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
@@ -60,6 +60,13 @@ private enum SignatureTokenMode
private bool _operatorReadScopeUnavailable;
private bool _pairingRequiredAwaitingApproval;
private IReadOnlyList? _userRules;
+ private bool _preferStructuredCategories = true;
+
+ ///
+ /// Controls whether structured notification metadata (Intent, Channel) takes priority
+ /// over keyword-based classification. Call after construction and whenever settings change.
+ ///
+ public void SetPreferStructuredCategories(bool value) => _preferStructuredCategories = value;
private void ResetUnsupportedMethodFlags()
{
@@ -2005,7 +2012,7 @@ private void EmitNotification(string text)
{
Message = text.Length > 200 ? text[..200] + "…" : text
};
- var (title, type) = _categorizer.Classify(notification, _userRules);
+ var (title, type) = _categorizer.Classify(notification, _userRules, _preferStructuredCategories);
notification.Title = title;
notification.Type = type;
NotificationReceived?.Invoke(this, notification);
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index 897358b..b895961 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -1103,6 +1103,7 @@ private void InitializeGatewayClient()
_gatewayClient = new OpenClawGatewayClient(_settings.GetEffectiveGatewayUrl(), _settings.Token, new AppLogger());
_gatewayClient.SetUserRules(_settings.UserRules.Count > 0 ? _settings.UserRules : null);
+ _gatewayClient.SetPreferStructuredCategories(_settings.PreferStructuredCategories);
_gatewayClient.StatusChanged += OnConnectionStatusChanged;
_gatewayClient.ActivityChanged += OnActivityChanged;
_gatewayClient.NotificationReceived += OnNotificationReceived;
diff --git a/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs b/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs
index bb2354c..556c3c3 100644
--- a/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs
+++ b/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs
@@ -272,6 +272,52 @@ public void PipelineOrder_Intent_Channel_UserRules_Keywords()
Assert.Equal("health", _categorizer.Classify(notification).type);
}
+ // --- PreferStructuredCategories = false ---
+
+ [Fact]
+ public void PreferStructuredCategories_False_SkipsIntent()
+ {
+ var notification = new OpenClawNotification { Message = "New email notification", Intent = "build" };
+ var (_, type) = _categorizer.Classify(notification, preferStructuredCategories: false);
+ Assert.Equal("email", type);
+ }
+
+ [Fact]
+ public void PreferStructuredCategories_False_SkipsChannel()
+ {
+ var notification = new OpenClawNotification { Message = "Check your email", Channel = "calendar" };
+ var (_, type) = _categorizer.Classify(notification, preferStructuredCategories: false);
+ Assert.Equal("email", type);
+ }
+
+ [Fact]
+ public void PreferStructuredCategories_False_UserRulesStillApply()
+ {
+ var rules = new List
+ {
+ new() { Pattern = "invoice", Category = "email", Enabled = true }
+ };
+ var notification = new OpenClawNotification { Message = "New invoice received", Intent = "urgent" };
+ var (_, type) = _categorizer.Classify(notification, rules, preferStructuredCategories: false);
+ Assert.Equal("email", type);
+ }
+
+ [Fact]
+ public void PreferStructuredCategories_False_FallsBackToKeywords()
+ {
+ var notification = new OpenClawNotification { Message = "Hello world", Intent = "build", Channel = "email" };
+ var (_, type) = _categorizer.Classify(notification, preferStructuredCategories: false);
+ Assert.Equal("info", type);
+ }
+
+ [Fact]
+ public void PreferStructuredCategories_True_Default_BehaviourUnchanged()
+ {
+ var notification = new OpenClawNotification { Message = "New email notification", Intent = "build" };
+ Assert.Equal("build", _categorizer.Classify(notification).type);
+ Assert.Equal("build", _categorizer.Classify(notification, preferStructuredCategories: true).type);
+ }
+
// --- ClassifyByKeywords static method ---
[Fact]