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
21 changes: 21 additions & 0 deletions src/Orbit.Api/Controllers/HabitsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Api.RateLimiting;
using Orbit.Application.Habits.Commands;
using Orbit.Application.Habits.Queries;
using Orbit.Domain.Interfaces;
Expand Down Expand Up @@ -186,6 +187,26 @@ public async Task<IActionResult> CreateHabit(
return result.ToPayGateAwareResult(v => CreatedAtAction(nameof(GetHabits), new { id = v }, new { id = v }));
}

[HttpPost("suggest-setup")]
[DistributedRateLimit("habit-suggest")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> SuggestSetup(
[FromBody] SuggestHabitSetupRequest request,
CancellationToken cancellationToken)
{
var command = new SuggestHabitSetupCommand(
HttpContext.GetUserId(),
request.Title,
request.Language);

var result = await mediator.Send(command, cancellationToken);
return result.ToPayGateAwareResult(v => Ok(v));
}

[HttpPost("{id:guid}/log")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Api/Controllers/HabitsControllerRequests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,6 @@ public record CreateSubHabitRequest(
string? Emoji = null);

public record LinkGoalsRequest(List<Guid> GoalIds);

public record SuggestHabitSetupRequest(string Title, string Language = "en");
}
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<IAiIntentService, AiIntentService>();
builder.Services.AddScoped<IFactExtractionService, AiFactExtractionService>();
builder.Services.AddScoped<ISummaryService, AiSummaryService>();
builder.Services.AddScoped<IHabitSuggestionService, AiHabitSuggestionService>();
builder.Services.AddScoped<IRetrospectiveService, AiRetrospectiveService>();
builder.Services.AddScoped<IGoalReviewService, AiGoalReviewService>();
builder.Services.AddScoped<IAgentCatalogService, AgentCatalogService>();
Expand Down
82 changes: 82 additions & 0 deletions src/Orbit.Application/Habits/Commands/SuggestHabitSetupCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Security.Cryptography;
using System.Text;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;
using Orbit.Domain.Models;

namespace Orbit.Application.Habits.Commands;

public record SuggestHabitSetupCommand(
Guid UserId,
string Title,
string Language) : IRequest<Result<HabitSetupSuggestion>>;

public partial class SuggestHabitSetupCommandHandler(
IPayGateService payGate,
IHabitSuggestionService suggestionService,
IGenericRepository<User> userRepository,
IUnitOfWork unitOfWork,
IMemoryCache cache,
ILogger<SuggestHabitSetupCommandHandler> logger)
: IRequestHandler<SuggestHabitSetupCommand, Result<HabitSetupSuggestion>>
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(1);

public async Task<Result<HabitSetupSuggestion>> Handle(
SuggestHabitSetupCommand request, CancellationToken cancellationToken)
{
var gateCheck = await payGate.CanSendAiMessage(request.UserId, cancellationToken);
if (gateCheck.IsFailure)
return gateCheck.PropagateError<HabitSetupSuggestion>();

var language = string.IsNullOrWhiteSpace(request.Language) ? "en" : request.Language;
var cacheKey = BuildCacheKey(request.UserId, request.Title, language);

if (cache.TryGetValue(cacheKey, out HabitSetupSuggestion? cached) && cached is not null)
return Result.Success(cached);

var suggestionResult = await suggestionService.SuggestSetupAsync(
request.Title, language, cancellationToken);
if (suggestionResult.IsFailure)
return suggestionResult;

await IncrementUsageAsync(request.UserId, cancellationToken);

cache.Set(cacheKey, suggestionResult.Value, CacheTtl);

return suggestionResult;
}

private async Task IncrementUsageAsync(Guid userId, CancellationToken cancellationToken)
{
var increment = await ConcurrencyRetry.ExecuteAsync(
userRepository,
unitOfWork,
ct => userRepository.FindOneTrackedAsync(user => user.Id == userId, cancellationToken: ct),
user =>
{
user.IncrementAiMessageCount();
return Task.FromResult(Result.Success());
},
ErrorMessages.UserNotFound,
cancellationToken);

if (increment.IsFailure)
LogUsageIncrementFailed(logger, userId);
}

private static string BuildCacheKey(Guid userId, string title, string language)
{
var normalizedTitle = title.Trim().ToLowerInvariant();
var titleHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalizedTitle)));
return $"suggest-setup:{userId}:{titleHash}:{language.ToLowerInvariant()}";
}

[LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Failed to increment AI message usage after a habit suggestion for user {UserId}")]
private static partial void LogUsageIncrementFailed(ILogger logger, Guid userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using FluentValidation;
using Orbit.Application.Common;
using Orbit.Application.Habits.Commands;

namespace Orbit.Application.Habits.Validators;

public class SuggestHabitSetupCommandValidator : AbstractValidator<SuggestHabitSetupCommand>
{
public SuggestHabitSetupCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty();

SharedHabitRules.AddTitleRules(RuleFor(x => x.Title));

RuleFor(x => x.Language)
.NotEmpty()
.MaximumLength(AppConstants.MaxLanguageLength)
.Must(lang => AppConstants.SupportedLanguages.Contains(lang))
.WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}");
}
}
14 changes: 14 additions & 0 deletions src/Orbit.Domain/Interfaces/IHabitSuggestionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Orbit.Domain.Common;
using Orbit.Domain.Models;

namespace Orbit.Domain.Interfaces;

public interface IHabitSuggestionService
{
/// <summary>
/// Asks the AI for a setup suggestion (emoji, schedule, sub-habit breakdown) for a habit with
/// the given title, written in the given language. Returns a sanitized suggestion on success, or
/// a failure when the AI produced no usable output or was unavailable.
/// </summary>
Task<Result<HabitSetupSuggestion>> SuggestSetupAsync(string title, string language, CancellationToken ct = default);
}
16 changes: 16 additions & 0 deletions src/Orbit.Domain/Models/HabitSetupSuggestion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Orbit.Domain.Enums;

namespace Orbit.Domain.Models;

/// <summary>
/// An AI-suggested starting point for a new habit: a representative emoji, a recurrence schedule
/// (or null fields for a one-time task), the weekdays it should run on (non-empty only for a daily
/// habit), and a breakdown into concrete sub-habit titles. Every field is optional so the user can
/// accept or edit any part; the shapes map 1:1 onto the create-habit request.
/// </summary>
public record HabitSetupSuggestion(
string? Emoji,
FrequencyUnit? FrequencyUnit,
int? FrequencyQuantity,
IReadOnlyList<DayOfWeek> Days,
IReadOnlyList<string> SubHabits);
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ private static AgentCapability[] HabitCoreCapabilities()
controllerActions:
[
"HabitsController.CreateHabit",
"HabitsController.SuggestSetup",
"HabitsController.LogHabit",
"HabitsController.SkipHabit",
"HabitsController.UpdateHabit",
Expand Down
133 changes: 133 additions & 0 deletions src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using Microsoft.Extensions.Logging;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;
using Orbit.Domain.Models;
using Orbit.Infrastructure.AI;

namespace Orbit.Infrastructure.Services;

public sealed partial class AiHabitSuggestionService(
AiCompletionClient aiClient,
ILogger<AiHabitSuggestionService> logger) : IHabitSuggestionService
{
private const int MaxSuggestedSubHabits = 6;

public async Task<Result<HabitSetupSuggestion>> SuggestSetupAsync(
string title, string language, CancellationToken ct = default)
{
var prompt = BuildPrompt(title, language);

if (logger.IsEnabled(LogLevel.Information))
LogGeneratingSuggestion(logger, language);

try
{
var dto = await aiClient.CompleteJsonAsync<HabitSuggestionDto>(
"You help set up a habit and reply with a single JSON object, nothing else.",
prompt,
cancellationToken: ct,
purpose: "habit_suggest",
tier: AiModelTier.SubTask);

return MapSuggestion(dto);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogSuggestionFailed(logger, ex);
return Result.Failure<HabitSetupSuggestion>(ErrorMessages.AiUnavailable);
}
}

internal static string BuildPrompt(string title, string language)
{
var languageName = LocaleHelper.GetAiLanguageName(language);

return $"""
A user is creating a habit titled "{title}".
Reply with a JSON object suggesting a sensible setup, using exactly these fields:
- "emoji": a single emoji that best represents the habit, or null.
- "frequencyUnit": one of "Day", "Week", "Month", "Year" for a recurring habit, or null for a one-time task.
- "frequencyQuantity": a positive integer meaning "once every N units" (unit "Day" quantity 1 means daily; unit "Week" quantity 2 means every two weeks). Use null when "frequencyUnit" is null.
- "days": an array of English weekday names ("Monday" through "Sunday") ONLY when the habit should run on specific weekdays with "frequencyUnit" "Day" and "frequencyQuantity" 1; otherwise an empty array.
- "subHabits": an array of up to {MaxSuggestedSubHabits} short, concrete sub-task titles that break the habit into actionable steps, ONLY when the habit is broad enough to benefit; otherwise an empty array.
Write any sub-habit titles in {languageName}. Respond with JSON only, no prose.
""";
}

internal static Result<HabitSetupSuggestion> MapSuggestion(HabitSuggestionDto? dto)
{
if (dto is null)
return Result.Failure<HabitSetupSuggestion>(ErrorMessages.AiEmptyResponse);

var frequencyUnit = ParseFrequencyUnit(dto.FrequencyUnit);
var frequencyQuantity = SanitizeQuantity(dto.FrequencyQuantity, frequencyUnit);
var days = SanitizeDays(dto.Days, frequencyUnit, frequencyQuantity);
var subHabits = SanitizeSubHabits(dto.SubHabits);

return Result.Success(new HabitSetupSuggestion(
SanitizeEmoji(dto.Emoji),
frequencyUnit,
frequencyQuantity,
days,
subHabits));
}

private static string? SanitizeEmoji(string? emoji)
{
var trimmed = emoji?.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.Length > AppConstants.MaxHabitEmojiLength)
return null;
return trimmed;
}

private static FrequencyUnit? ParseFrequencyUnit(string? value) =>
Enum.TryParse<FrequencyUnit>(value, ignoreCase: true, out var unit) ? unit : null;

private static int? SanitizeQuantity(int? quantity, FrequencyUnit? frequencyUnit)
{
if (frequencyUnit is null)
return null;
return quantity is { } value && value >= 1 ? value : 1;
}

private static IReadOnlyList<DayOfWeek> SanitizeDays(

Check warning on line 95 in src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change return type of method 'SanitizeDays' from 'System.Collections.Generic.IReadOnlyList<System.DayOfWeek>' to 'System.Collections.Generic.List<System.DayOfWeek>' for improved performance

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8CKImPqWa_Zows7eD8&open=AZ8CKImPqWa_Zows7eD8&pullRequest=257
IReadOnlyList<string>? days, FrequencyUnit? frequencyUnit, int? frequencyQuantity)
{
if (days is null || frequencyUnit != FrequencyUnit.Day || frequencyQuantity != 1)
return [];

return days
.Select(day => Enum.TryParse<DayOfWeek>(day, ignoreCase: true, out var parsed) ? parsed : (DayOfWeek?)null)
.Where(day => day is not null)
.Select(day => day!.Value)
.Distinct()
.ToList();
}

private static IReadOnlyList<string> SanitizeSubHabits(IReadOnlyList<string>? subHabits)

Check warning on line 109 in src/Orbit.Infrastructure/Services/AiHabitSuggestionService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change return type of method 'SanitizeSubHabits' from 'System.Collections.Generic.IReadOnlyList<string>' to 'System.Collections.Generic.List<string>' for improved performance

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8CKImPqWa_Zows7eD7&open=AZ8CKImPqWa_Zows7eD7&pullRequest=257
{
if (subHabits is null)
return [];

return subHabits
.Select(title => title?.Trim() ?? string.Empty)
.Where(title => title.Length > 0 && title.Length <= AppConstants.MaxHabitTitleLength)
.Take(MaxSuggestedSubHabits)
.ToList();
}

internal sealed record HabitSuggestionDto(
string? Emoji,
string? FrequencyUnit,
int? FrequencyQuantity,
IReadOnlyList<string>? Days,
IReadOnlyList<string>? SubHabits);

[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Generating habit setup suggestion (language: {Language})...")]
private static partial void LogGeneratingSuggestion(ILogger logger, string language);

[LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "AI API call failed for habit setup suggestion")]
private static partial void LogSuggestionFailed(ILogger logger, Exception ex);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class DistributedRateLimitService(OrbitDbContext dbContext, TimeProvider
["auth"] = new(TimeSpan.FromMinutes(1), PermitLimit: 10, SegmentCount: 1),
["chat"] = new(TimeSpan.FromMinutes(1), PermitLimit: 20, SegmentCount: 4),
["ai-resolve"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4),
["habit-suggest"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4),
["support"] = new(TimeSpan.FromHours(1), PermitLimit: 3, SegmentCount: 1),
["uploads"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4)
};
Expand Down
Loading
Loading