fix: honour PreferStructuredCategories in notification pipeline

When PreferStructuredCategories is false, classification skips structured metadata (Intent, Channel) and goes straight to user rules + keyword fallback.

5 new tests. 521 shared tests pass.

Reimplements #104 on current master.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Scott Hanselman 2026-04-01 00:05:50 -07:00 committed by GitHub
parent 1d836390e9
commit 344461d30d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 68 additions and 8 deletions

View File

@ -51,16 +51,22 @@ public class NotificationCategorizer
/// <summary>
/// Classify a notification using the layered pipeline.
/// When <paramref name="preferStructuredCategories"/> is true (default),
/// structured metadata (Intent, Channel) is checked first.
/// When false, classification starts from user-defined rules then keyword fallback.
/// </summary>
public (string title, string type) Classify(OpenClawNotification notification, IReadOnlyList<UserNotificationRule>? userRules = null)
public (string title, string type) Classify(OpenClawNotification notification, IReadOnlyList<UserNotificationRule>? 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 })

View File

@ -60,6 +60,13 @@ public class OpenClawGatewayClient : WebSocketClientBase
private bool _operatorReadScopeUnavailable;
private bool _pairingRequiredAwaitingApproval;
private IReadOnlyList<UserNotificationRule>? _userRules;
private bool _preferStructuredCategories = true;
/// <summary>
/// Controls whether structured notification metadata (Intent, Channel) takes priority
/// over keyword-based classification. Call after construction and whenever settings change.
/// </summary>
public void SetPreferStructuredCategories(bool value) => _preferStructuredCategories = value;
private void ResetUnsupportedMethodFlags()
{
@ -2005,7 +2012,7 @@ public class OpenClawGatewayClient : WebSocketClientBase
{
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);

View File

@ -1103,6 +1103,7 @@ public partial class App : Application
_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;

View File

@ -272,6 +272,52 @@ public class NotificationCategorizerTests
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<UserNotificationRule>
{
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]