Repository navigation
feat(api): AI tag suggestion endpoint (#223) #259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
8e9f7a8
1250840
c083852
b6d86bc
fe33ad6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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( | ||
| 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(); | ||
| } |
| 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)}"); | ||
| } | ||
| } |
| 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 |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| 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>( | ||
|
Check failure on line 29 in src/Orbit.Infrastructure/Services/AiTagSuggestionService.cs
|
||
| 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); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[HIGH] Missing
[DistributedRateLimit]onSuggestTagsEvery other AI-invoking endpoint in the codebase carries a
[DistributedRateLimit]attribute —ChatControllerat class level ("chat"),AiController.ResolveConflictat action level ("ai-resolve"). This endpoint makes a paid OpenAI call on every invocation; without the attribute, concurrent authenticated requests all pass the logicalCanSendAiMessagequota gate before the first meter commit lands, enabling unbounded API spend.