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
22 changes: 22 additions & 0 deletions src/Orbit.Api/Controllers/TagsController.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.Tags.Commands;
using Orbit.Application.Tags.Queries;

Expand All @@ -17,6 +18,7 @@ public partial class TagsController(IMediator mediator, ILogger<TagsController>
public record CreateTagRequest(string Name, string Color);
public record UpdateTagRequest(string Name, string Color);
public record AssignTagsRequest(IReadOnlyList<Guid> TagIds);
public record SuggestTagsRequest(string Title, string? Description, string? Language);

[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
Expand Down Expand Up @@ -100,6 +102,26 @@ public async Task<IActionResult> AssignTags(
return result.ToPayGateAwareResult(() => NoContent());
}

[HttpPost("suggest")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Missing [DistributedRateLimit] on SuggestTags

Every other AI-invoking endpoint in the codebase carries a [DistributedRateLimit] attribute — ChatController at class level ("chat"), AiController.ResolveConflict at action level ("ai-resolve"). This endpoint makes a paid OpenAI call on every invocation; without the attribute, concurrent authenticated requests all pass the logical CanSendAiMessage quota gate before the first meter commit lands, enabling unbounded API spend.

Suggested change
[HttpPost("suggest")]
[DistributedRateLimit("ai")]
[HttpPost("suggest")]

[DistributedRateLimit("tag-suggest")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> SuggestTags(
[FromBody] SuggestTagsRequest request,
CancellationToken cancellationToken)
{
var query = new SuggestTagsQuery(
HttpContext.GetUserId(),
request.Title,
request.Description,
request.Language ?? "en");
var result = await mediator.Send(query, cancellationToken);

return result.ToPayGateAwareResult(v => Ok(v));
}

[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Tag created {TagId} by user {UserId}")]
private static partial void LogTagCreated(ILogger logger, Guid tagId, Guid userId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ private static void AddAiPlatformServices(WebApplicationBuilder builder)
builder.Services.AddScoped<IHabitSuggestionService, AiHabitSuggestionService>();
builder.Services.AddScoped<IRetrospectiveService, AiRetrospectiveService>();
builder.Services.AddScoped<IGoalReviewService, AiGoalReviewService>();
builder.Services.AddScoped<ITagSuggestionService, AiTagSuggestionService>();
builder.Services.AddScoped<IAgentCatalogService, AgentCatalogService>();
builder.Services.AddScoped<IPendingAgentOperationStore, PendingAgentOperationStore>();
builder.Services.AddScoped<IPendingClarificationStore, PendingClarificationStore>();
Expand Down
1 change: 1 addition & 0 deletions src/Orbit.Application/Common/ErrorMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public static class ErrorMessages
public static readonly AppError AiUnavailable = new(ErrorCodes.AiUnavailable, "AI service temporarily unavailable");
public static readonly AppError AiRetrospectiveUnavailable = new(ErrorCodes.AiUnavailable, "AI retrospective temporarily unavailable");
public static readonly AppError AiGoalReviewUnavailable = new(ErrorCodes.AiUnavailable, "AI goal review temporarily unavailable");
public static readonly AppError AiTagSuggestionUnavailable = new(ErrorCodes.AiUnavailable, "AI tag suggestion temporarily unavailable");
public static readonly AppError AiSummaryUnavailable = new(ErrorCodes.AiUnavailable, "AI summary temporarily unavailable");
public static readonly AppError AiRescheduleUnavailable = new(ErrorCodes.AiUnavailable, "AI reschedule temporarily unavailable");
public static readonly AppError HabitNotOverdue = new(ErrorCodes.HabitNotOverdue, "Habit is not overdue.");
Expand Down
98 changes: 98 additions & 0 deletions src/Orbit.Application/Tags/Queries/SuggestTagsQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using MediatR;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Tags.Queries;

public record SuggestedTag(string Name, string Color, bool IsExisting, Guid? Id);

public record SuggestTagsResponse(IReadOnlyList<SuggestedTag> Tags);

public record SuggestTagsQuery(Guid UserId, string Title, string? Description, string Language)
: IRequest<Result<SuggestTagsResponse>>;

public class SuggestTagsQueryHandler(
IPayGateService payGate,
ITagSuggestionService tagSuggestionService,
IGenericRepository<Tag> tagRepository,
IGenericRepository<User> userRepository,
IUnitOfWork unitOfWork) : IRequestHandler<SuggestTagsQuery, Result<SuggestTagsResponse>>
{
private const string NewTagColor = "#7c3aed";

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

var existingTags = await tagRepository.FindAsync(
tag => tag.UserId == request.UserId,
cancellationToken);

var existingNames = existingTags.Select(tag => tag.Name).ToList();

var suggestionResult = await tagSuggestionService.SuggestTagsAsync(
request.Title,
request.Description,
existingNames,
request.Language,
cancellationToken);

if (suggestionResult.IsFailure)
return suggestionResult.PropagateError<SuggestTagsResponse>();

var suggestions = MapSuggestions(suggestionResult.Value, existingTags);

await MeterAiMessageAsync(request.UserId, cancellationToken);

return Result.Success(new SuggestTagsResponse(suggestions));
}

private static IReadOnlyList<SuggestedTag> MapSuggestions(

Check warning on line 56 in src/Orbit.Application/Tags/Queries/SuggestTagsQuery.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change return type of method 'MapSuggestions' from 'System.Collections.Generic.IReadOnlyList<Orbit.Application.Tags.Queries.SuggestedTag>' to 'System.Collections.Generic.List<Orbit.Application.Tags.Queries.SuggestedTag>' for improved performance

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8CS473mADGP9d-yz75&open=AZ8CS473mADGP9d-yz75&pullRequest=259
IReadOnlyList<string> suggestedNames,
IReadOnlyList<Tag> existingTags)
{
var existingByName = existingTags
.GroupBy(tag => tag.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);

var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var mapped = new List<SuggestedTag>();

foreach (var rawName in suggestedNames)
{
var capitalized = Capitalize(rawName.Trim());
if (string.IsNullOrEmpty(capitalized) || !seen.Add(capitalized))
continue;

mapped.Add(existingByName.TryGetValue(capitalized, out var existing)
? new SuggestedTag(existing.Name, existing.Color, IsExisting: true, existing.Id)
: new SuggestedTag(capitalized, NewTagColor, IsExisting: false, Id: null));

if (mapped.Count >= AppConstants.MaxTagsPerHabit)
break;
}

return mapped;
}

private async Task MeterAiMessageAsync(Guid userId, CancellationToken cancellationToken)
{
var user = await userRepository.FindOneTrackedAsync(
candidate => candidate.Id == userId,
cancellationToken: cancellationToken);
if (user is null)
return;

user.IncrementAiMessageCount();
await unitOfWork.SaveChangesAsync(cancellationToken);
}

private static string Capitalize(string value) =>
string.IsNullOrEmpty(value) ? value : char.ToUpper(value[0]) + value[1..].ToLower();
}
27 changes: 27 additions & 0 deletions src/Orbit.Application/Tags/Validators/SuggestTagsQueryValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using FluentValidation;
using Orbit.Application.Common;
using Orbit.Application.Tags.Queries;

namespace Orbit.Application.Tags.Validators;

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

RuleFor(x => x.Title)
.NotEmpty()
.MaximumLength(AppConstants.MaxHabitTitleLength);

RuleFor(x => x.Description)
.MaximumLength(AppConstants.MaxHabitDescriptionLength);

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

namespace Orbit.Domain.Interfaces;

public interface ITagSuggestionService
{
Task<Result<IReadOnlyList<string>>> SuggestTagsAsync(
string title,
string? description,
IReadOnlyList<string> existingTagNames,
string language,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ private static AgentCapability[] TagCapabilities()
AgentConfirmationRequirement.None,
chatTools: ["assign_tags", "create_tag", "update_tag"],
mcpTools: ["create_tag", "update_tag", "assign_tags"],
controllerActions: ["TagsController.CreateTag", "TagsController.UpdateTag", "TagsController.AssignTags"]),
controllerActions: ["TagsController.CreateTag", "TagsController.UpdateTag", "TagsController.AssignTags", "TagsController.SuggestTags"]),

CreateCapability(
AgentCapabilityIds.TagsDelete,
Expand Down
95 changes: 95 additions & 0 deletions src/Orbit.Infrastructure/Services/AiTagSuggestionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Microsoft.Extensions.Logging;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Interfaces;
using Orbit.Infrastructure.AI;

namespace Orbit.Infrastructure.Services;

public sealed partial class AiTagSuggestionService(
AiCompletionClient aiClient,
ILogger<AiTagSuggestionService> logger) : ITagSuggestionService
{
private sealed record TagSuggestionResult(List<string> Tags);

public async Task<Result<IReadOnlyList<string>>> SuggestTagsAsync(
string title,
string? description,
IReadOnlyList<string> existingTagNames,
string language,
CancellationToken cancellationToken = default)
{
var prompt = BuildPrompt(title, description, existingTagNames, language);

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

try
{
var completion = await aiClient.CompleteJsonAsync<TagSuggestionResult>(
"You suggest tags for a habit and reply with a single JSON object, nothing else.",
prompt,
cancellationToken: cancellationToken,
purpose: "tag_suggestion",
tier: AiModelTier.SubTask);

var cleaned = completion?.Tags?
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Trim())
.ToList();

if (cleaned is null || cleaned.Count == 0)
return Result.Failure<IReadOnlyList<string>>(ErrorMessages.AiEmptyResponse);

if (logger.IsEnabled(LogLevel.Information))
LogTagSuggestionsGenerated(logger, cleaned.Count);

return Result.Success<IReadOnlyList<string>>(cleaned);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogTagSuggestionFailed(logger, ex);
return Result.Failure<IReadOnlyList<string>>(ErrorMessages.AiTagSuggestionUnavailable);
}
}

internal static string BuildPrompt(
string title,
string? description,
IReadOnlyList<string> existingTagNames,
string language)
{
var languageName = LocaleHelper.GetAiLanguageName(language);
var existingTagsBlock = existingTagNames.Count > 0
? string.Join(", ", existingTagNames)
: "(none yet)";
var descriptionLine = string.IsNullOrWhiteSpace(description)
? "(no description)"
: description.Trim();

return $$"""
HABIT
Title: {{title}}
Description: {{descriptionLine}}

EXISTING TAGS (reuse one of these verbatim whenever it fits instead of inventing a near-duplicate):
{{existingTagsBlock}}

RULES
- Suggest 1 to 5 short tags that categorize this habit.
- Each tag is 1-2 words written in {{languageName}}.
- Strongly prefer an existing tag over a new near-duplicate.
- No '#' prefix, no punctuation, no duplicates.
- Respond only with JSON in this exact shape: { "tags": ["tag1", "tag2"] }
""";
}

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

[LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "Tag suggestions generated ({Count} tags)")]
private static partial void LogTagSuggestionsGenerated(ILogger logger, int count);

[LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "AI API call failed for tag suggestion")]
private static partial void LogTagSuggestionFailed(ILogger logger, Exception ex);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public class DistributedRateLimitService(OrbitDbContext dbContext, TimeProvider
["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)
["uploads"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4),
["tag-suggest"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4)
};

public async Task<DistributedRateLimitDecision> TryAcquireAsync(
Expand Down
Loading
Loading