diff --git a/src/OpenClaw.Shared/NotificationCategorizer.cs b/src/OpenClaw.Shared/NotificationCategorizer.cs index 8559d69..20d61a2 100644 --- a/src/OpenClaw.Shared/NotificationCategorizer.cs +++ b/src/OpenClaw.Shared/NotificationCategorizer.cs @@ -51,16 +51,23 @@ public class NotificationCategorizer /// /// Classify a notification using the layered pipeline. + /// When is (the default), + /// structured metadata (Intent, Channel) is checked first. + /// When , structured metadata is skipped and 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 0e21836..ccc08d9 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -22,6 +22,17 @@ public class OpenClawGatewayClient : WebSocketClientBase private bool _usageCostUnsupported; private bool _sessionPreviewUnsupported; private bool _nodeListUnsupported; + private bool _preferStructuredCategories = true; + + /// + /// Controls whether structured notification metadata (Intent, Channel) takes priority + /// over keyword-based classification. Mirrors the PreferStructuredCategories + /// setting. Call after construction and whenever settings change. + /// + public void SetPreferStructuredCategories(bool value) + { + _preferStructuredCategories = value; + } private void ResetUnsupportedMethodFlags() { @@ -851,7 +862,7 @@ private void EmitChatNotification(string text) Message = displayText, IsChat = true }; - var (title, type) = _categorizer.Classify(notification); + var (title, type) = _categorizer.Classify(notification, preferStructuredCategories: _preferStructuredCategories); notification.Title = title; notification.Type = type; NotificationReceived?.Invoke(this, notification); @@ -1556,7 +1567,7 @@ private void EmitNotification(string text) { Message = text.Length > 200 ? text[..200] + "…" : text }; - var (title, type) = _categorizer.Classify(notification); + var (title, type) = _categorizer.Classify(notification, preferStructuredCategories: _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 de0780f..e9cde48 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1085,6 +1085,7 @@ private void InitializeGatewayClient() UnsubscribeGatewayEvents(); _gatewayClient = new OpenClawGatewayClient(_settings.GatewayUrl, _settings.Token, new AppLogger()); + _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..723ba6c 100644 --- a/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs +++ b/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs @@ -272,7 +272,82 @@ public void PipelineOrder_Intent_Channel_UserRules_Keywords() Assert.Equal("health", _categorizer.Classify(notification).type); } - // --- ClassifyByKeywords static method --- + // --- PreferStructuredCategories = false --- + + [Fact] + public void PreferStructuredCategories_False_SkipsIntent() + { + // Intent says "build" but with preferStructuredCategories=false it should be ignored; + // message keyword "email" drives the result. + 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() + { + // Channel says "calendar" but with preferStructuredCategories=false it should be ignored; + // message keyword "email" drives the result. + 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 } + }; + // Intent would win when preferStructuredCategories=true, but is skipped here; + // user rule matches the message. + 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() + { + // No user rules, no keywords for "hello world" → "info" + 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() + { + // Ensure the default (true) still gives structured metadata priority. + 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); + } + + [Fact] public void ClassifyByKeywords_DefaultsToInfo()