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
10 changes: 8 additions & 2 deletions src/Orbit.Api/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ public partial class ChatController(IMediator mediator, IImageValidationService
{
private static readonly JsonSerializerOptions ChatHistoryJsonOptions = new() { PropertyNameCaseInsensitive = true };

private const long MaxChatRequestBytes = 10 * 1024 * 1024;

[HttpPost]
[RequestSizeLimit(10_485_760)] [RequestFormLimits(MultipartBodyLengthLimit = 10_485_760)] [ProducesResponseType(StatusCodes.Status200OK)]
[RequestSizeLimit(MaxChatRequestBytes)]
[RequestFormLimits(MultipartBodyLengthLimit = MaxChatRequestBytes)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
Expand Down Expand Up @@ -70,7 +74,9 @@ public async Task<IActionResult> ProcessChat(
}

[HttpPost("stream")]
[RequestSizeLimit(10_485_760)] [RequestFormLimits(MultipartBodyLengthLimit = 10_485_760)] [ProducesResponseType(StatusCodes.Status200OK)]
[RequestSizeLimit(MaxChatRequestBytes)]
[RequestFormLimits(MultipartBodyLengthLimit = MaxChatRequestBytes)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ProcessChatStream(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ private async Task<ToolResult> AssignByNamesAsync(Guid habitId, JsonElement tagN
if (habit is null)
return new ToolResult(false, Error: $"Habit {habitId} not found.");

var resolvedTags = await ResolveTagsByNameAsync(tagNames, userId, ct);
var resolvedTags = await HabitToolHelpers.ResolveOrCreateTagsAsync(tagRepository, tagNames, userId, ct);
return await ReplaceTagsAsync(habit, resolvedTags, ct);
}

Expand All @@ -107,43 +107,4 @@ private async Task<ToolResult> ReplaceTagsAsync(Habit habit, List<Tag> resolvedT

return new ToolResult(true, EntityId: habit.Id.ToString(), EntityName: habit.Title);
}

private async Task<List<Tag>> ResolveTagsByNameAsync(List<string> tagNames, Guid userId, CancellationToken ct)
{
var capitalizedNames = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in tagNames)
{
var capitalized = Capitalize(name.Trim());
if (!string.IsNullOrEmpty(capitalized) && seen.Add(capitalized))
capitalizedNames.Add(capitalized);
}

var existingByName = (await tagRepository.FindTrackedAsync(
t => t.UserId == userId && capitalizedNames.Contains(t.Name), ct))
.ToDictionary(t => t.Name, StringComparer.Ordinal);

var resolved = new List<Tag>();
foreach (var capitalized in capitalizedNames)
{
if (existingByName.TryGetValue(capitalized, out var existing))
{
resolved.Add(existing);
}
else
{
var createResult = Tag.Create(userId, capitalized, "#7c3aed");
if (createResult.IsSuccess)
{
await tagRepository.AddAsync(createResult.Value, ct);
resolved.Add(createResult.Value);
}
}
}

return resolved;
}

private static string Capitalize(string s) =>
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s[1..].ToLower();
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Orbit.Application.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

Expand Down Expand Up @@ -37,43 +35,13 @@ public class BulkLogHabitsTool(
required = new[] { "habit_ids" }
};

public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct)
{
if (!args.TryGetProperty("habit_ids", out var idsEl) || idsEl.ValueKind != JsonValueKind.Array)
return new ToolResult(false, Error: "habit_ids is required and must be an array of GUIDs.");

var habitIds = new List<Guid>();
foreach (var el in idsEl.EnumerateArray())
{
if (Guid.TryParse(el.GetString(), out var id))
habitIds.Add(id);
}

if (habitIds.Count == 0)
return new ToolResult(false, Error: "No valid habit IDs provided.");

var today = await userDateService.GetUserTodayAsync(userId, ct);
var targetDate = JsonArgumentParser.ParseDateOnly(args, "date") ?? today;
var loggedNames = new List<string>();

var habits = await habitRepository.FindTrackedAsync(
h => habitIds.Contains(h.Id) && h.UserId == userId,
q => q.Include(h => h.Logs),
public Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) =>
HabitToolHelpers.RunBulkHabitActionAsync(
habitRepository, userDateService, args, userId,
"No habits were logged. They may already be completed or not found.",
(habit, targetDate, _) => TryLogHabit(habit, targetDate, ct),
ct);

foreach (var habitId in habitIds)
{
var habit = habits.FirstOrDefault(h => h.Id == habitId);
if (habit is not null && await TryLogHabit(habit, targetDate, ct))
loggedNames.Add(habit.Title);
}

if (loggedNames.Count == 0)
return new ToolResult(false, Error: "No habits were logged. They may already be completed or not found.");

return new ToolResult(true, EntityName: string.Join(", ", loggedNames));
}

private async Task<bool> TryLogHabit(Habit habit, DateOnly targetDate, CancellationToken ct)
{
if (habit.Logs.Any(l => l.Date == targetDate))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Orbit.Application.Habits.Services;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;
Expand Down Expand Up @@ -39,40 +38,13 @@ public class BulkSkipHabitsTool(

public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct)
{
if (!args.TryGetProperty("habit_ids", out var idsEl) || idsEl.ValueKind != JsonValueKind.Array)
return new ToolResult(false, Error: "habit_ids is required and must be an array of GUIDs.");

var habitIds = new List<Guid>();
foreach (var el in idsEl.EnumerateArray())
{
if (Guid.TryParse(el.GetString(), out var id))
habitIds.Add(id);
}

if (habitIds.Count == 0)
return new ToolResult(false, Error: "No valid habit IDs provided.");

var today = await userDateService.GetUserTodayAsync(userId, ct);
var weekStartDay = await userDateService.GetUserWeekStartDayAsync(userId, ct);
var targetDate = JsonArgumentParser.ParseDateOnly(args, "date") ?? today;
var skippedNames = new List<string>();

var habits = await habitRepository.FindTrackedAsync(
h => habitIds.Contains(h.Id) && h.UserId == userId,
q => q.Include(h => h.Logs),
return await HabitToolHelpers.RunBulkHabitActionAsync(
habitRepository, userDateService, args, userId,
"No habits were skipped. They may be completed, not yet due, or not found.",
(habit, targetDate, today) => TrySkipHabit(habit, targetDate, today, weekStartDay, ct),
ct);

foreach (var habitId in habitIds)
{
var habit = habits.FirstOrDefault(h => h.Id == habitId);
if (habit is not null && await TrySkipHabit(habit, targetDate, today, weekStartDay, ct))
skippedNames.Add(habit.Title);
}

if (skippedNames.Count == 0)
return new ToolResult(false, Error: "No habits were skipped. They may be completed, not yet due, or not found.");

return new ToolResult(true, EntityName: string.Join(", ", skippedNames));
}

private async Task<bool> TrySkipHabit(Habit habit, DateOnly targetDate, DateOnly today, int weekStartDay, CancellationToken ct)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,42 +294,16 @@ private async Task LinkGoalsFromArgsAsync(JsonElement args, Habit habit, Guid us

private async Task AssignTagsToHabitAsync(Habit habit, List<string> tagNames, Guid userId, CancellationToken ct)
{
var capitalizedNames = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in tagNames)
{
var capitalized = Capitalize(name.Trim());
if (!string.IsNullOrEmpty(capitalized) && seen.Add(capitalized))
capitalizedNames.Add(capitalized);
}

var existingByName = (await tagRepository.FindTrackedAsync(
t => t.UserId == userId && capitalizedNames.Contains(t.Name), ct))
.ToDictionary(t => t.Name, StringComparer.Ordinal);

foreach (var capitalized in capitalizedNames)
{
if (existingByName.TryGetValue(capitalized, out var existing))
{
habit.AddTag(existing);
}
else
{
var createResult = Tag.Create(userId, capitalized, "#7c3aed");
if (createResult.IsSuccess)
{
await tagRepository.AddAsync(createResult.Value, ct);
habit.AddTag(createResult.Value);
}
}
}
var tags = await HabitToolHelpers.ResolveOrCreateTagsAsync(tagRepository, tagNames, userId, ct);
foreach (var tag in tags)
habit.AddTag(tag);
}

private static string Capitalize(string s) =>
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s[1..].ToLower();

private static readonly System.Text.RegularExpressions.Regex HabitWordRegex =
new(@"\bhabit\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
new(
@"\bhabit\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled,
TimeSpan.FromSeconds(1));

private static bool IsHabitFlavoredTitle(string title)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,12 @@ public class DeleteHabitTool(

public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct)
{
if (!args.TryGetProperty("habit_id", out var habitIdEl) ||
!Guid.TryParse(habitIdEl.GetString(), out var habitId))
return new ToolResult(false, Error: "habit_id is required and must be a valid GUID.");

var habit = await habitRepository.FindOneTrackedAsync(
h => h.Id == habitId && h.UserId == userId,
cancellationToken: ct);
if (!HabitToolHelpers.TryParseHabitId(args, out var habitId))
return HabitToolHelpers.InvalidHabitIdResult();

var habit = await HabitToolHelpers.FindHabitAsync(habitRepository, habitId, userId, ct);
if (habit is null)
return new ToolResult(false, Error: $"Habit {habitId} not found.");
return HabitToolHelpers.HabitNotFoundResult(habitId);

var title = habit.Title;
habitRepository.Remove(habit);
Expand Down
138 changes: 138 additions & 0 deletions src/Orbit.Application/Chat/Tools/Implementations/HabitToolHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Chat.Tools.Implementations;

/// <summary>
/// Shared execution helpers for the habit-oriented AI tools: single-habit resolution,
/// bulk habit-id parsing/iteration, and tag find-or-create. Centralizes the boilerplate
/// those tools would otherwise duplicate verbatim.
/// </summary>
internal static class HabitToolHelpers
{
private const string TagColor = "#7c3aed";

public static bool TryParseHabitId(JsonElement args, out Guid habitId)
{
habitId = Guid.Empty;
return args.TryGetProperty("habit_id", out var habitIdEl)
&& Guid.TryParse(habitIdEl.GetString(), out habitId);
}

public static ToolResult InvalidHabitIdResult() =>
new(false, Error: "habit_id is required and must be a valid GUID.");

public static ToolResult HabitNotFoundResult(Guid habitId) =>
new(false, Error: $"Habit {habitId} not found.");

public static Task<Habit?> FindHabitAsync(
IGenericRepository<Habit> habitRepository, Guid habitId, Guid userId, CancellationToken ct) =>
habitRepository.FindOneTrackedAsync(
h => h.Id == habitId && h.UserId == userId,
cancellationToken: ct);

private static (List<Guid> HabitIds, ToolResult? Error) ParseHabitIds(JsonElement args)
{
if (!args.TryGetProperty("habit_ids", out var idsEl) || idsEl.ValueKind != JsonValueKind.Array)
return (new List<Guid>(), new ToolResult(false, Error: "habit_ids is required and must be an array of GUIDs."));

var habitIds = new List<Guid>();
foreach (var el in idsEl.EnumerateArray())
{
if (Guid.TryParse(el.GetString(), out var id))
habitIds.Add(id);
}

if (habitIds.Count == 0)
return (habitIds, new ToolResult(false, Error: "No valid habit IDs provided."));

return (habitIds, null);
}

/// <summary>
/// Runs a bulk habit action end to end: parses <c>habit_ids</c>, resolves the target date, loads the
/// requested habits with their logs, applies <paramref name="tryApply"/> (given the habit, target date,
/// and today) to each in request order, and returns a result naming the habits the action succeeded on,
/// or <paramref name="noneAppliedError"/> when none did.
/// </summary>
public static async Task<ToolResult> RunBulkHabitActionAsync(
IGenericRepository<Habit> habitRepository,
IUserDateService userDateService,
JsonElement args,
Guid userId,
string noneAppliedError,
Func<Habit, DateOnly, DateOnly, Task<bool>> tryApply,
CancellationToken ct)
{
var (habitIds, parseError) = ParseHabitIds(args);
if (parseError is not null)
return parseError;

var today = await userDateService.GetUserTodayAsync(userId, ct);
var targetDate = JsonArgumentParser.ParseDateOnly(args, "date") ?? today;

var habits = await habitRepository.FindTrackedAsync(
h => habitIds.Contains(h.Id) && h.UserId == userId,
q => q.Include(h => h.Logs),
ct);

var appliedTitles = new List<string>();
foreach (var habitId in habitIds)
{
var habit = habits.FirstOrDefault(h => h.Id == habitId);
if (habit is not null && await tryApply(habit, targetDate, today))
appliedTitles.Add(habit.Title);
}

if (appliedTitles.Count == 0)
return new ToolResult(false, Error: noneAppliedError);

return new ToolResult(true, EntityName: string.Join(", ", appliedTitles));
}

/// <summary>
/// Resolves tag names to entities, reusing the user's existing tags (case-insensitive, capitalized)
/// and creating any that are missing. Newly created tags are added to the repository.
/// </summary>
public static async Task<List<Tag>> ResolveOrCreateTagsAsync(
IGenericRepository<Tag> tagRepository, IEnumerable<string> tagNames, Guid userId, CancellationToken ct)
{
var capitalizedNames = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in tagNames)
{
var capitalized = Capitalize(name.Trim());
if (!string.IsNullOrEmpty(capitalized) && seen.Add(capitalized))
capitalizedNames.Add(capitalized);
}

var existingByName = (await tagRepository.FindTrackedAsync(
t => t.UserId == userId && capitalizedNames.Contains(t.Name), ct))
.ToDictionary(t => t.Name, StringComparer.Ordinal);

var resolved = new List<Tag>();
foreach (var capitalized in capitalizedNames)
{
if (existingByName.TryGetValue(capitalized, out var existing))
{
resolved.Add(existing);
}
else
{
var createResult = Tag.Create(userId, capitalized, TagColor);
if (createResult.IsSuccess)
{
await tagRepository.AddAsync(createResult.Value, ct);
resolved.Add(createResult.Value);
}
}
}

return resolved;
}

private static string Capitalize(string s) =>
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s[1..].ToLower();
}
Loading
Loading