Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/OpenClaw.Shared/NotificationCategorizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,23 @@ public class NotificationCategorizer

/// <summary>
/// Classify a notification using the layered pipeline.
/// When <paramref name="preferStructuredCategories"/> is <see langword="true"/> (the default),
/// structured metadata (Intent, Channel) is checked first.
/// When <see langword="false"/>, structured metadata is skipped and 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 })
Expand Down
15 changes: 13 additions & 2 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ public class OpenClawGatewayClient : WebSocketClientBase
private bool _usageCostUnsupported;
private bool _sessionPreviewUnsupported;
private bool _nodeListUnsupported;
private bool _preferStructuredCategories = true;

/// <summary>
/// Controls whether structured notification metadata (Intent, Channel) takes priority
/// over keyword-based classification. Mirrors the <c>PreferStructuredCategories</c>
/// setting. Call after construction and whenever settings change.
/// </summary>
public void SetPreferStructuredCategories(bool value)
{
_preferStructuredCategories = value;
}

private void ResetUnsupportedMethodFlags()
{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
77 changes: 76 additions & 1 deletion tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserNotificationRule>
{
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()
Expand Down