Skip to content
Merged
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
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

56 changes: 48 additions & 8 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -1763,6 +1763,12 @@
"handlerFile": "src/Orbit.Application/Habits/Commands/BulkSkipHabitsCommand.cs",
"featureFolder": "Habits"
},
{
"request": "BulkUpdateHabitsCommand",
"response": "Result<BulkHabitMutationResult>",
"handlerFile": "src/Orbit.Application/Habits/Commands/BulkUpdateHabitsCommand.cs",
"featureFolder": "Habits"
},
{
"request": "CheckInAccountabilityCommand",
"response": "Result<Guid>",
Expand Down Expand Up @@ -2697,6 +2703,10 @@
"orphans": {
"endpointsWithoutHandler": [],
"handlersWithoutEndpoint": [
{
"request": "BulkUpdateHabitsCommand",
"handlerFile": "src/Orbit.Application/Habits/Commands/BulkUpdateHabitsCommand.cs"
},
{
"request": "CheckReferralCompletionCommand",
"handlerFile": "src/Orbit.Application/Referrals/Commands/CheckReferralCompletionCommand.cs"
Expand Down Expand Up @@ -2733,12 +2743,12 @@
"Behaviors": 4,
"Calendar": 15,
"Challenges": 15,
"Chat": 80,
"Chat": 84,
"ChecklistTemplates": 5,
"Common": 36,
"Gamification": 33,
"Goals": 35,
"Habits": 75,
"Habits": 78,
"Marketing": 4,
"Notifications": 13,
"Profile": 37,
Expand Down Expand Up @@ -2942,12 +2952,12 @@
"Behaviors": 14,
"Calendar": 16,
"Challenges": 14,
"Chat": 61,
"Chat": 63,
"ChecklistTemplates": 9,
"Common": 193,
"Common": 197,
"Gamification": 43,
"Goals": 54,
"Habits": 90,
"Habits": 93,
"Marketing": 6,
"Notifications": 20,
"Profile": 40,
Expand Down Expand Up @@ -3328,6 +3338,11 @@
"file": "tests/Orbit.Application.Tests/Chat/AiToolRegistryTests.cs",
"references": []
},
{
"testClass": "BulkToolRepeatGuardTests",
"file": "tests/Orbit.Application.Tests/Chat/BulkToolRepeatGuardTests.cs",
"references": []
},
{
"testClass": "ChatFaqCacheTests",
"file": "tests/Orbit.Application.Tests/Chat/ChatFaqCacheTests.cs",
Expand Down Expand Up @@ -3421,7 +3436,15 @@
"testClass": "BulkDeleteHabitsToolTests",
"file": "tests/Orbit.Application.Tests/Chat/Tools/BulkDeleteHabitsToolTests.cs",
"references": [
"BulkDeleteHabitsCommand"
"BulkDeleteHabitsCommand",
"Habit"
]
},
{
"testClass": "BulkHabitToolArgumentsTests",
"file": "tests/Orbit.Application.Tests/Chat/Tools/BulkHabitToolArgumentsTests.cs",
"references": [
"Tag"
]
},
{
Expand All @@ -3436,8 +3459,8 @@
"testClass": "BulkSkipHabitsToolTests",
"file": "tests/Orbit.Application.Tests/Chat/Tools/BulkSkipHabitsToolTests.cs",
"references": [
"Habit",
"HabitLog"
"BulkSkipHabitsCommand",
"Habit"
]
},
{
Expand Down Expand Up @@ -4188,6 +4211,16 @@
"HabitLog"
]
},
{
"testClass": "BulkUpdateHabitsCommandHandlerTests",
"file": "tests/Orbit.Application.Tests/Commands/Habits/BulkUpdateHabitsCommandHandlerTests.cs",
"references": [
"BulkUpdateHabitsCommand",
"BulkUpdateHabitsCommandHandler",
"Habit",
"Tag"
]
},
{
"testClass": "CreateHabitCommandHandlerTests",
"file": "tests/Orbit.Application.Tests/Commands/Habits/CreateHabitCommandHandlerTests.cs",
Expand Down Expand Up @@ -5864,6 +5897,13 @@
"BulkSkipHabitsCommand"
]
},
{
"testClass": "BulkUpdateHabitsCommandValidatorTests",
"file": "tests/Orbit.Application.Tests/Validators/BulkUpdateHabitsCommandValidatorTests.cs",
"references": [
"BulkUpdateHabitsCommand"
]
},
{
"testClass": "ConfirmAccountDeletionCommandValidatorTests",
"file": "tests/Orbit.Application.Tests/Validators/ConfirmAccountDeletionCommandValidatorTests.cs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ private static void AddAiPlatformServices(WebApplicationBuilder builder)
builder.Services.AddScoped<IAudioTranscriptionService, AudioTranscriptionService>();
builder.Services.AddScoped<IAiIntentService, AiIntentService>();
builder.Services.AddScoped<IFactExtractionService, AiFactExtractionService>();
builder.Services.AddScoped<IHabitEmojiInferenceService, AiHabitEmojiInferenceService>();
builder.Services.AddScoped<ISummaryService, AiSummaryService>();
builder.Services.AddScoped<IRescheduleSuggestionService, AiRescheduleSuggestionService>();
builder.Services.AddScoped<IHabitSuggestionService, AiHabitSuggestionService>();
Expand Down Expand Up @@ -55,6 +56,8 @@ private static void AddAiChatTools(WebApplicationBuilder builder)
builder.Services.AddScoped<IAiTool, SuggestBreakdownTool>();
builder.Services.AddScoped<IAiTool, DuplicateHabitTool>();
builder.Services.AddScoped<IAiTool, MoveHabitTool>();
builder.Services.AddScoped<IAiTool, BulkUpdateHabitsTool>();
builder.Services.AddScoped<IAiTool, BulkRescheduleHabitsTool>();
builder.Services.AddScoped<IAiTool, BulkUpdateHabitEmojisTool>();
builder.Services.AddScoped<IAiTool, BulkLogHabitsTool>();
builder.Services.AddScoped<IAiTool, BulkSkipHabitsTool>();
Expand Down
96 changes: 74 additions & 22 deletions src/Orbit.Api/Mcp/Tools/HabitTools.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Security.Claims;
using System.Text.Json;
using MediatR;
using ModelContextProtocol.Server;
using Orbit.Api.Mcp;
Expand Down Expand Up @@ -272,11 +273,13 @@ public async Task<string> GetHabitLogs(
if (logs.Count == 0)
return "No logs found for this habit.";

var lines = logs.Take(50).Select(l =>
var returnedLogs = logs.Take(50).ToList();
var partial = returnedLogs.Count < logs.Count;
var lines = returnedLogs.Select(l =>
$"- {l.Date:yyyy-MM-dd}" +
$" (id: {l.Id})");

return $"Logs ({logs.Count} total, showing up to 50):\n{string.Join("\n", lines)}";
return $"Logs (total: {logs.Count}, returned: {returnedLogs.Count}, partial: {partial.ToString().ToLowerInvariant()}):\n{string.Join("\n", lines)}";
}

[McpServerTool(Name = "get_all_habit_logs"), Description("Get completion logs for all habits within a date range, grouped by habit ID.")]
Expand All @@ -297,10 +300,16 @@ public async Task<string> GetAllHabitLogs(
if (grouped.Count == 0)
return "No logs found for the given date range.";

var lines = grouped.Select(g =>
$"Habit {g.Key}: {g.Value.Count} logs ({string.Join(", ", g.Value.Take(10).Select(l => l.Date.ToString("yyyy-MM-dd")))})");
var totalLogs = grouped.Sum(group => group.Value.Count);
var returnedLogCount = grouped.Sum(group => Math.Min(group.Value.Count, 10));
var partial = returnedLogCount < totalLogs;
var lines = grouped.Select(group =>
{
var returned = group.Value.Take(10).ToList();
return $"Habit {group.Key}: total {group.Value.Count}, returned {returned.Count} ({string.Join(", ", returned.Select(log => log.Date.ToString("yyyy-MM-dd")))})";
});

return $"Logs for {grouped.Count} habits:\n{string.Join("\n", lines)}";
return $"Logs for {grouped.Count} habits (total_logs: {totalLogs}, returned_logs: {returnedLogCount}, partial: {partial.ToString().ToLowerInvariant()}):\n{string.Join("\n", lines)}";
}

[McpServerTool(Name = "create_sub_habit"), Description("Create a sub-habit under an existing parent habit. Requires Pro subscription.")]
Expand Down Expand Up @@ -383,57 +392,100 @@ public async Task<string> BulkCreateHabits(
[McpServerTool(Name = "bulk_delete_habits"), Description("Delete multiple habits at once.")]
public async Task<string> BulkDeleteHabits(
ClaimsPrincipal user,
[Description("Comma-separated habit IDs (GUIDs)")] string habitIds,
[Description("Comma-separated habit IDs (GUIDs). Omit when filterJson is provided.")] string? habitIds = null,
[Description("Confirmation token returned by confirm_agent_operation_v2 (required: bulk delete is destructive)")] string? confirmationToken = null,
[Description("Optional JSON server-side filter used instead of habitIds")] string? filterJson = null,
CancellationToken cancellationToken = default)
{
var ids = McpToolHelpers.ParseGuidCsv(habitIds);
var ids = string.IsNullOrWhiteSpace(habitIds) ? [] : McpToolHelpers.ParseGuidCsv(habitIds);
var filter = string.IsNullOrWhiteSpace(filterJson) ? (JsonElement?)null : JsonSerializer.Deserialize<JsonElement>(filterJson);

var result = await executorBridge.ExecuteAsync(user, "bulk_delete_habits", new
{
habit_ids = ids.Select(id => id.ToString())
habit_ids = ids.Count > 0 ? ids.Select(id => id.ToString()) : null,
filter
}, confirmationToken, cancellationToken);

if (!result.Succeeded)
return result.Message;

if (result.Payload is not BulkDeleteResult bulk)
return $"Bulk delete: {result.TargetName}";
return result.TargetName ?? "Bulk delete completed.";
}

var successCount = bulk.Results.Count(x => x.Status == BulkItemStatus.Success);
return $"Bulk delete: {successCount}/{ids.Count} deleted successfully";
[McpServerTool(Name = "bulk_update_habits"), Description("Update the full server-side set of habits matching a JSON filter in one operation.")]
public async Task<string> BulkUpdateHabits(
ClaimsPrincipal user,
[Description("JSON filter with all, habit_ids, tag, search, is_completed, is_general, is_bad_habit, or frequency")] string filterJson,
[Description("JSON object containing the habit fields to update")] string updatesJson,
[Description("Confirmation token returned by confirm_agent_operation_v2")] string? confirmationToken = null,
CancellationToken cancellationToken = default)
{
var filter = JsonSerializer.Deserialize<JsonElement>(filterJson);
var updates = JsonSerializer.Deserialize<JsonElement>(updatesJson);
var result = await executorBridge.ExecuteAsync(user, "bulk_update_habits", new
{
filter,
updates
}, confirmationToken, cancellationToken);

return result.Succeeded ? result.TargetName ?? "Bulk habit update completed." : result.Message;
}

[McpServerTool(Name = "bulk_reschedule_habits"), Description("Reschedule the full server-side set of habits matching a JSON filter in one operation.")]
public async Task<string> BulkRescheduleHabits(
ClaimsPrincipal user,
[Description("JSON filter with all, habit_ids, tag, search, is_completed, is_general, is_bad_habit, or frequency")] string filterJson,
[Description("New due date in YYYY-MM-DD format")] string dueDate,
[Description("Confirmation token returned by confirm_agent_operation_v2")] string? confirmationToken = null,
CancellationToken cancellationToken = default)
{
var filter = JsonSerializer.Deserialize<JsonElement>(filterJson);
var result = await executorBridge.ExecuteAsync(user, "bulk_reschedule_habits", new
{
filter,
due_date = dueDate
}, confirmationToken, cancellationToken);

return result.Succeeded ? result.TargetName ?? "Bulk habit reschedule completed." : result.Message;
}

[McpServerTool(Name = "bulk_log_habits"), Description("Log multiple habits as completed at once.")]
public Task<string> BulkLogHabits(
ClaimsPrincipal user,
[Description("Comma-separated habit IDs (GUIDs)")] string habitIds,
[Description("Comma-separated habit IDs (GUIDs). Omit when filterJson is provided.")] string? habitIds = null,
[Description("Date to log for in YYYY-MM-DD format (defaults to today)")] string? date = null,
[Description("Optional JSON server-side filter used instead of habitIds")] string? filterJson = null,
CancellationToken cancellationToken = default) =>
ExecuteBulkHabitOperationAsync(user, "bulk_log_habits", "Bulk log", habitIds, date, cancellationToken);
ExecuteBulkHabitOperationAsync(user, "bulk_log_habits", habitIds, date, filterJson, cancellationToken);

[McpServerTool(Name = "bulk_skip_habits"), Description("Skip multiple habits at once.")]
public Task<string> BulkSkipHabits(
ClaimsPrincipal user,
[Description("Comma-separated habit IDs (GUIDs)")] string habitIds,
[Description("Comma-separated habit IDs (GUIDs). Omit when filterJson is provided.")] string? habitIds = null,
[Description("Date to skip in YYYY-MM-DD format (defaults to today)")] string? date = null,
[Description("Optional JSON server-side filter used instead of habitIds")] string? filterJson = null,
CancellationToken cancellationToken = default) =>
ExecuteBulkHabitOperationAsync(user, "bulk_skip_habits", "Bulk skip", habitIds, date, cancellationToken);
ExecuteBulkHabitOperationAsync(user, "bulk_skip_habits", habitIds, date, filterJson, cancellationToken);

private async Task<string> ExecuteBulkHabitOperationAsync(
ClaimsPrincipal user, string operation, string resultLabel, string habitIds, string? date, CancellationToken cancellationToken)
ClaimsPrincipal user,
string operation,
string? habitIds,
string? date,
string? filterJson,
CancellationToken cancellationToken)
{
var ids = McpToolHelpers.ParseGuidCsv(habitIds);
var ids = string.IsNullOrWhiteSpace(habitIds) ? [] : McpToolHelpers.ParseGuidCsv(habitIds);
var filter = string.IsNullOrWhiteSpace(filterJson) ? (JsonElement?)null : JsonSerializer.Deserialize<JsonElement>(filterJson);

var result = await executorBridge.ExecuteAsync(user, operation, new
{
habit_ids = ids.Select(id => id.ToString()),
habit_ids = ids.Count > 0 ? ids.Select(id => id.ToString()) : null,
filter,
date
}, confirmationToken: null, cancellationToken);

return result.Succeeded
? $"{resultLabel}: {ids.Count} habit(s) processed ({result.TargetName})"
: result.Message;
return result.Succeeded ? result.TargetName ?? "Bulk habit operation completed." : result.Message;
}

[McpServerTool(Name = "reorder_habits"), Description("Reorder habits by setting new positions.")]
Expand Down
84 changes: 84 additions & 0 deletions src/Orbit.Application/Chat/BulkToolRepeatGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.Text.Json;
using Orbit.Domain.Models;

namespace Orbit.Application.Chat;

public static class BulkToolRepeatGuard
{
public const int Threshold = 3;

private static readonly IReadOnlyDictionary<string, string> BulkAlternatives =

Check warning on line 10 in src/Orbit.Application/Chat/BulkToolRepeatGuard.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change type of field 'BulkAlternatives' from 'System.Collections.Generic.IReadOnlyDictionary<string, string>' to 'System.Collections.Generic.Dictionary<string, string>' for improved performance

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AaCRNyXuDubu3FwyOHQy&open=AaCRNyXuDubu3FwyOHQy&pullRequest=519
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["update_habit"] = "bulk_update_habits",
["log_habit"] = "bulk_log_habits",
["skip_habit"] = "bulk_skip_habits",
["delete_habit"] = "bulk_delete_habits"
};

public static IReadOnlyDictionary<string, string> FindRedirects(IReadOnlyList<AiToolCall> calls)

Check failure on line 19 in src/Orbit.Application/Chat/BulkToolRepeatGuard.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AaCRZgzo4ug9F89KJpuc&open=AaCRZgzo4ug9F89KJpuc&pullRequest=519
{
var redirects = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var toolGroup in calls.GroupBy(call => call.Name, StringComparer.OrdinalIgnoreCase))
{
if (!BulkAlternatives.TryGetValue(toolGroup.Key, out var bulkTool))
continue;

var remaining = toolGroup.ToList();
while (remaining.Count > 0)
{
var seed = remaining[0];
remaining.RemoveAt(0);
var equivalent = new List<AiToolCall> { seed };
for (var index = remaining.Count - 1; index >= 0; index--)
{
if (!HaveEquivalentMutationArguments(seed.Args, remaining[index].Args))
continue;
equivalent.Add(remaining[index]);
remaining.RemoveAt(index);
}

if (equivalent.Count < Threshold)
continue;
foreach (var call in equivalent)
redirects[call.Id] = bulkTool;
}
}
return redirects;
}

private static bool HaveEquivalentMutationArguments(JsonElement left, JsonElement right) =>
HaveEquivalentJson(left, right, ignoreHabitId: true);

private static bool HaveEquivalentJson(JsonElement left, JsonElement right, bool ignoreHabitId = false)
{
if (left.ValueKind != right.ValueKind)
return false;

if (left.ValueKind == JsonValueKind.Object)
{
var leftProperties = left.EnumerateObject()
.Where(property => !ignoreHabitId || property.Name != "habit_id")
.OrderBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
var rightProperties = right.EnumerateObject()
.Where(property => !ignoreHabitId || property.Name != "habit_id")
.OrderBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
return leftProperties.Length == rightProperties.Length
&& leftProperties.Zip(rightProperties).All(pair =>
pair.First.Name == pair.Second.Name
&& HaveEquivalentJson(pair.First.Value, pair.Second.Value));
}

if (left.ValueKind == JsonValueKind.Array)
{
var leftItems = left.EnumerateArray().ToArray();
var rightItems = right.EnumerateArray().ToArray();
return leftItems.Length == rightItems.Length
&& leftItems.Zip(rightItems).All(pair => HaveEquivalentJson(pair.First, pair.Second));
}

return left.GetRawText() == right.GetRawText();
}
}
Loading
Loading