-
Notifications
You must be signed in to change notification settings - Fork 0
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
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
8e9f7a8
feat(api): AI tag suggestion endpoint (#223)
thomasluizon 1250840
fix(api): enforce supported-language allowlist on tag suggestion (#223)
thomasluizon c083852
fix(api): rate-limit tag suggestion + catalog it as a write capabilit…
thomasluizon b6d86bc
Merge branch 'main' into issue-223
thomasluizon fe33ad6
fix(api): adapt tag-suggest call to generalized CompleteJsonAsync (#223)
thomasluizon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
|
||
| 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
27
src/Orbit.Application/Tags/Validators/SuggestTagsQueryValidator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)}"); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
src/Orbit.Infrastructure/Services/AiTagSuggestionService.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.