diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index c1ec285d..407d3f77 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -117,6 +117,35 @@ public async Task GetDailySummary( return result.ToPayGateAwareResult(v => Ok(v)); } + [HttpGet("retrospective")] + public async Task GetRetrospective( + [FromQuery] string period, + [FromQuery] string language = "en", + CancellationToken cancellationToken = default) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var days = period.ToLowerInvariant() switch + { + "week" => 7, + "month" => 30, + "quarter" => 90, + "semester" => 180, + "year" => 365, + _ => 7 + }; + var dateFrom = today.AddDays(-days); + + var query = new GetRetrospectiveQuery( + HttpContext.GetUserId(), + dateFrom, + today, + period, + language); + + var result = await mediator.Send(query, cancellationToken); + return result.ToPayGateAwareResult(v => Ok(v)); + } + [HttpGet("{id:guid}")] public async Task GetHabitById(Guid id, CancellationToken cancellationToken) { diff --git a/src/Orbit.Api/Program.cs b/src/Orbit.Api/Program.cs index 1b655610..9efbf03a 100644 --- a/src/Orbit.Api/Program.cs +++ b/src/Orbit.Api/Program.cs @@ -158,6 +158,9 @@ // Daily summary always uses Gemini (free-text generation) builder.Services.AddHttpClient(); +// Retrospective always uses Gemini (free-text generation) +builder.Services.AddHttpClient(); + // --- In-Memory Cache --- builder.Services.AddMemoryCache(); diff --git a/src/Orbit.Application/Common/PayGateService.cs b/src/Orbit.Application/Common/PayGateService.cs index 1007b0fc..4ba259e5 100644 --- a/src/Orbit.Application/Common/PayGateService.cs +++ b/src/Orbit.Application/Common/PayGateService.cs @@ -70,6 +70,19 @@ public async Task CanUseDailySummary(Guid userId, CancellationToken ct = return Result.Success(); } + public async Task CanUseRetrospective(Guid userId, CancellationToken ct = default) + { + var user = await userRepository.GetByIdAsync(userId, ct); + if (user is null) + return Result.Failure("User not found."); + + var proOnly = await appConfig.GetAsync("RetrospectiveProOnly", true, ct); + if (proOnly && !user.HasProAccess) + return Result.PayGateFailure("Retrospectives are a Pro feature. Upgrade to unlock!"); + + return Result.Success(); + } + /// /// Returns the AI message limit for the given user (used by profile/subscription endpoints). /// diff --git a/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs b/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs new file mode 100644 index 00000000..d6b9d3d3 --- /dev/null +++ b/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs @@ -0,0 +1,67 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Habits.Queries; + +public record RetrospectiveResponse(string Retrospective, bool FromCache); + +public record GetRetrospectiveQuery( + Guid UserId, + DateOnly DateFrom, + DateOnly DateTo, + string Period, + string Language) : IRequest>; + +public class GetRetrospectiveQueryHandler( + IGenericRepository habitRepository, + IPayGateService payGate, + IRetrospectiveService retrospectiveService, + IMemoryCache cache) : IRequestHandler> +{ + public async Task> Handle( + GetRetrospectiveQuery request, + CancellationToken cancellationToken) + { + var gateCheck = await payGate.CanUseRetrospective(request.UserId, cancellationToken); + if (gateCheck.IsFailure) + return gateCheck.PropagateError(); + + var cacheKey = $"retro:{request.UserId}:{request.Period}:{request.DateFrom}:{request.Language}"; + + if (cache.TryGetValue(cacheKey, out string? cached) && cached is not null) + return Result.Success(new RetrospectiveResponse(cached, FromCache: true)); + + var habits = await habitRepository.FindAsync( + h => h.UserId == request.UserId && h.IsActive, + q => q.Include(h => h.Logs.Where(l => l.Date >= request.DateFrom && l.Date <= request.DateTo)), + cancellationToken); + + var habitList = habits.ToList(); + + if (habitList.Count == 0) + return Result.Failure("No habits found for this period."); + + var result = await retrospectiveService.GenerateRetrospectiveAsync( + habitList, + request.DateFrom, + request.DateTo, + request.Period, + request.Language, + cancellationToken); + + if (result.IsFailure) + return Result.Failure(result.Error); + + cache.Set(cacheKey, result.Value, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) + }); + + return Result.Success(new RetrospectiveResponse(result.Value, FromCache: false)); + } +} diff --git a/src/Orbit.Domain/Interfaces/IPayGateService.cs b/src/Orbit.Domain/Interfaces/IPayGateService.cs index 617de3aa..39d39887 100644 --- a/src/Orbit.Domain/Interfaces/IPayGateService.cs +++ b/src/Orbit.Domain/Interfaces/IPayGateService.cs @@ -24,6 +24,11 @@ public interface IPayGateService /// Task CanUseDailySummary(Guid userId, CancellationToken ct = default); + /// + /// Checks if the user can use AI retrospectives (Pro-only feature). + /// + Task CanUseRetrospective(Guid userId, CancellationToken ct = default); + /// /// Returns the AI message limit for the given user. /// diff --git a/src/Orbit.Domain/Interfaces/IRetrospectiveService.cs b/src/Orbit.Domain/Interfaces/IRetrospectiveService.cs new file mode 100644 index 00000000..302a7187 --- /dev/null +++ b/src/Orbit.Domain/Interfaces/IRetrospectiveService.cs @@ -0,0 +1,15 @@ +using Orbit.Domain.Common; +using Orbit.Domain.Entities; + +namespace Orbit.Domain.Interfaces; + +public interface IRetrospectiveService +{ + Task> GenerateRetrospectiveAsync( + List habits, + DateOnly dateFrom, + DateOnly dateTo, + string period, + string language, + CancellationToken cancellationToken = default); +} diff --git a/src/Orbit.Infrastructure/Services/GeminiRetrospectiveService.cs b/src/Orbit.Infrastructure/Services/GeminiRetrospectiveService.cs new file mode 100644 index 00000000..e0e99516 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/GeminiRetrospectiveService.cs @@ -0,0 +1,237 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orbit.Application.Habits.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; + +namespace Orbit.Infrastructure.Services; + +public sealed class GeminiRetrospectiveService( + HttpClient httpClient, + IOptions options, + ILogger logger) : IRetrospectiveService +{ + private readonly GeminiSettings _settings = options.Value; + + public async Task> GenerateRetrospectiveAsync( + List habits, + DateOnly dateFrom, + DateOnly dateTo, + string period, + string language, + CancellationToken cancellationToken = default) + { + if (habits.Count == 0) + return Result.Failure("No habits found for this period."); + + var prompt = BuildRetrospectivePrompt(habits, dateFrom, dateTo, period, language); + + logger.LogInformation( + "Calling Gemini API for retrospective (period: {Period}, from: {From}, to: {To}, language: {Language})...", + period, dateFrom, dateTo, language); + + var request = new GeminiRequest + { + Contents = + [ + new GeminiContent + { + Parts = [new GeminiPart { Text = prompt }] + } + ], + GenerationConfig = new GeminiGenerationConfig + { + Temperature = 0.7 + } + }; + + HttpResponseMessage? response = null; + int retryCount = 0; + const int maxRetries = 3; + + while (retryCount <= maxRetries) + { + response = await httpClient.PostAsJsonAsync( + $"{_settings.BaseUrl}/models/{_settings.Model}:generateContent?key={_settings.ApiKey}", + request, + cancellationToken); + + if (response.IsSuccessStatusCode || response.StatusCode != System.Net.HttpStatusCode.TooManyRequests) + break; + + retryCount++; + if (retryCount <= maxRetries) + { + var delayMs = (int)Math.Pow(2, retryCount) * 1000; + logger.LogWarning("Rate limited by Gemini. Retrying in {DelayMs}ms (attempt {Retry}/{Max})...", + delayMs, retryCount, maxRetries); + await Task.Delay(delayMs, cancellationToken); + } + } + + if (!response!.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogError("Gemini API returned {Status}: {Body}", response.StatusCode, errorBody); + return Result.Failure($"Gemini API error: {response.StatusCode}"); + } + + var geminiResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + var text = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text; + + if (string.IsNullOrWhiteSpace(text)) + { + logger.LogWarning("Gemini returned empty response for retrospective"); + return Result.Failure("Gemini returned empty response"); + } + + var trimmed = text.Trim(); + if (trimmed.StartsWith("```")) + { + var lines = trimmed.Split('\n'); + if (lines.Length >= 2) + trimmed = string.Join('\n', lines.Skip(1).Take(lines.Length - (lines[^1].TrimStart().StartsWith("```") ? 2 : 1))); + } + + logger.LogInformation("Retrospective generated successfully ({Length} chars)", trimmed.Trim().Length); + + return Result.Success(trimmed.Trim()); + } + + private static string BuildRetrospectivePrompt( + List habits, + DateOnly dateFrom, + DateOnly dateTo, + string period, + string language) + { + var languageName = language.ToLowerInvariant() switch + { + "pt-br" or "pt" => "Brazilian Portuguese", + _ => "English" + }; + + var totalDays = dateTo.DayNumber - dateFrom.DayNumber + 1; + + var habitLines = new List(); + var totalCompletions = 0; + var totalScheduled = 0; + var badHabitSlips = 0; + + foreach (var habit in habits.Where(h => h.ParentHabitId is null)) + { + var scheduledDates = HabitScheduleService.GetScheduledDates(habit, dateFrom, dateTo); + var scheduledCount = scheduledDates.Count; + var logs = habit.Logs.Where(l => l.Date >= dateFrom && l.Date <= dateTo).ToList(); + var completedCount = logs.Count; + + if (scheduledCount == 0 && completedCount == 0) + continue; + + totalScheduled += scheduledCount; + totalCompletions += completedCount; + + var rate = scheduledCount > 0 ? (int)Math.Round(100.0 * completedCount / scheduledCount) : 0; + + if (habit.IsBadHabit) + { + badHabitSlips += completedCount; + habitLines.Add($"- {habit.Title} (bad habit): {completedCount} slips in {totalDays} days"); + } + else + { + habitLines.Add($"- {habit.Title}: {completedCount}/{scheduledCount} completed ({rate}%)"); + } + + // Sub-habits + var children = habits.Where(h => h.ParentHabitId == habit.Id).ToList(); + foreach (var child in children) + { + var childLogs = child.Logs.Where(l => l.Date >= dateFrom && l.Date <= dateTo).ToList(); + var childScheduled = HabitScheduleService.GetScheduledDates(child, dateFrom, dateTo).Count; + var childRate = childScheduled > 0 ? (int)Math.Round(100.0 * childLogs.Count / childScheduled) : 0; + habitLines.Add($" - {child.Title}: {childLogs.Count}/{childScheduled} ({childRate}%)"); + } + } + + var habitSection = habitLines.Count > 0 + ? string.Join("\n", habitLines) + : "(no habit activity)"; + + var overallRate = totalScheduled > 0 ? (int)Math.Round(100.0 * totalCompletions / totalScheduled) : 0; + + return $""" + You are a thoughtful habit coach writing a retrospective review. + + Period: Last {totalDays} days ({period}) -- {dateFrom:MMMM d} to {dateTo:MMMM d, yyyy} + Total habits tracked: {habits.Count(h => h.ParentHabitId is null)} + Overall completion rate: {totalCompletions}/{totalScheduled} ({overallRate}%) + {(badHabitSlips > 0 ? $"Bad habit slips: {badHabitSlips}" : "")} + + Per-habit breakdown: + {habitSection} + + Write a retrospective with these sections (use these exact headings): + 1. **Highlights** -- What went well, milestones reached, strong completion rates + 2. **Missed Opportunities** -- What was skipped or neglected, patterns of avoidance + 3. **Trends** -- Improving or declining patterns, consistency observations + 4. **Suggestion** -- One specific, actionable tip for the next period + + Rules: + - Be honest but encouraging -- celebrate real wins, acknowledge real gaps + - Reference specific habit names and numbers from the data + - Keep each section to 2-3 sentences max + - If a habit has 100% completion, call it out as a win + - If a habit has <50% completion, flag it kindly + - For bad habits, fewer slips = good progress + - Do NOT use emojis or JSON + - Use markdown bold for section headings only + - Write ONLY in {languageName} + """; + } + + // --- Gemini API DTOs (same as GeminiSummaryService) --- + + private record GeminiRequest + { + [JsonPropertyName("contents")] + public GeminiContent[] Contents { get; init; } = []; + + [JsonPropertyName("generationConfig")] + public GeminiGenerationConfig? GenerationConfig { get; init; } + } + + private record GeminiContent + { + [JsonPropertyName("parts")] + public GeminiPart[] Parts { get; init; } = []; + } + + private record GeminiPart + { + [JsonPropertyName("text")] + public string Text { get; init; } = string.Empty; + } + + private record GeminiGenerationConfig + { + [JsonPropertyName("temperature")] + public double Temperature { get; init; } + } + + private record GeminiResponse + { + [JsonPropertyName("candidates")] + public GeminiCandidate[]? Candidates { get; init; } + } + + private record GeminiCandidate + { + [JsonPropertyName("content")] + public GeminiContent? Content { get; init; } + } +}