From 6f03cb373d6fb0bbff20b21d374091a6b7b01510 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 14 Jun 2026 20:28:21 -0300 Subject: [PATCH] feat(retrospective): structured metrics + narrative contract with empty-period guard Rework GetRetrospectiveQuery to return computed metrics (completion rate, totals, active days, current/best streak, bad-habit slips, weekly consistency, top habits, needs-attention) plus a structured narrative, replacing the freeform LLM text so the client can render a stats dashboard. Add an empty-period guard: when a period has no completions and no bad-habit slips, fail with NoHabitsForPeriod instead of calling the LLM, so the UI shows a dedicated empty state rather than a hallucinated summary. - RetrospectiveMetricsCalculator computes metrics from habit logs - RetrospectiveNarrative domain model for the structured sections - AiRetrospectiveService builds the narrative; camelCase JSON serialization - Tests updated + added for the empty-period failure path Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Orbit.Api/Mcp/Tools/HabitTools.cs | 6 +- .../Habits/Queries/GetRetrospectiveQuery.cs | 64 ++++- .../RetrospectiveMetricsCalculator.cs | 147 ++++++++++ .../Interfaces/IRetrospectiveService.cs | 3 +- .../Models/RetrospectiveNarrative.cs | 12 + .../Services/AiRetrospectiveService.cs | 100 ++++++- .../Chat/Tools/GetRetrospectiveToolTests.cs | 16 +- .../GetRetrospectiveQueryHandlerTests.cs | 267 ++++++++++++++---- .../Mcp/HabitToolsTests.cs | 9 +- .../Services/AiRetrospectiveServiceTests.cs | 86 +++++- 10 files changed, 625 insertions(+), 85 deletions(-) create mode 100644 src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs create mode 100644 src/Orbit.Domain/Models/RetrospectiveNarrative.cs diff --git a/src/Orbit.Api/Mcp/Tools/HabitTools.cs b/src/Orbit.Api/Mcp/Tools/HabitTools.cs index fa3eb2b7..80968491 100644 --- a/src/Orbit.Api/Mcp/Tools/HabitTools.cs +++ b/src/Orbit.Api/Mcp/Tools/HabitTools.cs @@ -538,7 +538,11 @@ public async Task GetRetrospective( return $"Error: {result.Error}"; var r = result.Value; - return $"Retrospective ({period}){(r.FromCache ? " (cached)" : "")}:\n{r.Retrospective}"; + var n = r.Narrative; + var narrativeText = string.Join( + "\n\n", + new[] { n.Highlights, n.Missed, n.Trends, n.Suggestion }.Where(s => !string.IsNullOrWhiteSpace(s))); + return $"Retrospective ({period}){(r.FromCache ? " (cached)" : "")}:\n{narrativeText}"; } private static Guid GetUserId(ClaimsPrincipal user) diff --git a/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs b/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs index 45d81b70..a6f7d0b9 100644 --- a/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs @@ -2,13 +2,39 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; +using Orbit.Application.Habits.Services; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; namespace Orbit.Application.Habits.Queries; -public record RetrospectiveResponse(string Retrospective, bool FromCache); +public record RetrospectiveHabitStat( + string Name, + string? Emoji, + int CompletionRate, + int CompletedCount, + int ScheduledCount); + +public record RetrospectiveMetrics( + int CompletionRate, + int TotalCompletions, + int TotalScheduled, + int ActiveDays, + int PeriodDays, + int CurrentStreak, + int BestStreak, + int BadHabitSlips, + IReadOnlyList WeeklyConsistency, + IReadOnlyList TopHabits, + IReadOnlyList NeedsAttention); + +public record RetrospectiveResponse( + string Period, + RetrospectiveMetrics Metrics, + RetrospectiveNarrative Narrative, + bool FromCache); public record GetRetrospectiveQuery( Guid UserId, @@ -21,6 +47,7 @@ public class GetRetrospectiveQueryHandler( IGenericRepository habitRepository, IPayGateService payGate, IRetrospectiveService retrospectiveService, + IUserStreakService userStreakService, IMemoryCache cache) : IRequestHandler> { public async Task> Handle( @@ -31,10 +58,10 @@ public async Task> Handle( if (gateCheck.IsFailure) return gateCheck.PropagateError(); - var cacheKey = $"retro:{request.UserId}:{request.Period}:{request.DateFrom}:{request.Language}"; + var cacheKey = $"retro:v2:{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)); + if (cache.TryGetValue(cacheKey, out RetrospectiveResponse? cached) && cached is not null) + return Result.Success(cached with { FromCache = true }); var habits = await habitRepository.FindAsync( h => h.UserId == request.UserId, @@ -46,7 +73,20 @@ public async Task> Handle( if (habitList.Count == 0) return Result.Failure(ErrorMessages.NoHabitsForPeriod); - var result = await retrospectiveService.GenerateRetrospectiveAsync( + var streakState = await userStreakService.RecalculateAsync( + request.UserId, cancellationToken, awardFreezeIfEligible: false); + + var metrics = RetrospectiveMetricsCalculator.Compute( + habitList, + request.DateFrom, + request.DateTo, + streakState?.CurrentStreak ?? 0, + streakState?.LongestStreak ?? 0); + + if (metrics.TotalCompletions == 0 && metrics.BadHabitSlips == 0) + return Result.Failure(ErrorMessages.NoHabitsForPeriod); + + var narrativeResult = await retrospectiveService.GenerateRetrospectiveAsync( habitList, request.DateFrom, request.DateTo, @@ -54,14 +94,20 @@ public async Task> Handle( request.Language, cancellationToken); - if (result.IsFailure) - return result.PropagateError(); + if (narrativeResult.IsFailure) + return narrativeResult.PropagateError(); + + var response = new RetrospectiveResponse( + request.Period, + metrics, + narrativeResult.Value, + FromCache: false); - cache.Set(cacheKey, result.Value, new MemoryCacheEntryOptions + cache.Set(cacheKey, response, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) }); - return Result.Success(new RetrospectiveResponse(result.Value, FromCache: false)); + return Result.Success(response); } } diff --git a/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs b/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs new file mode 100644 index 00000000..7ac7c85b --- /dev/null +++ b/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs @@ -0,0 +1,147 @@ +using Orbit.Application.Habits.Queries; +using Orbit.Domain.Entities; + +namespace Orbit.Application.Habits.Services; + +/// +/// Computes the structured, deterministic metrics shown on the retrospective dashboard +/// (completion rates, streak echo, active/period days, per-weekday consistency, and the +/// top / needs-attention habit lists) from the habits and in-range logs already loaded by +/// the query handler. The AI narrative is produced separately by . +/// +public static class RetrospectiveMetricsCalculator +{ + private const int MaxHabitStats = 3; + + private static readonly DayOfWeek[] WeekOrder = + [ + DayOfWeek.Monday, + DayOfWeek.Tuesday, + DayOfWeek.Wednesday, + DayOfWeek.Thursday, + DayOfWeek.Friday, + DayOfWeek.Saturday, + DayOfWeek.Sunday + ]; + + public static RetrospectiveMetrics Compute( + List habits, + DateOnly dateFrom, + DateOnly dateTo, + int currentStreak, + int bestStreak) + { + var trackedHabits = habits.Where(h => h.ParentHabitId is null).ToList(); + + var totalCompletions = 0; + var totalScheduled = 0; + var badHabitSlips = 0; + var stats = new List(); + var weekdayScheduled = new int[7]; + var weekdayCompleted = new int[7]; + + foreach (var habit in trackedHabits) + { + var scheduledDates = HabitScheduleService.GetScheduledDates(habit, dateFrom, dateTo); + var completedCount = habit.Logs.Count(l => l.Date >= dateFrom && l.Date <= dateTo && l.Value > 0); + + if (scheduledDates.Count == 0 && completedCount == 0) + continue; + + if (habit.IsBadHabit) + { + badHabitSlips += completedCount; + continue; + } + + totalScheduled += scheduledDates.Count; + totalCompletions += completedCount; + + AccumulateWeekdayConsistency(habit, scheduledDates, weekdayScheduled, weekdayCompleted); + stats.Add(BuildHabitStat(habit, scheduledDates.Count, completedCount)); + } + + var completionRate = Percent(totalCompletions, totalScheduled); + var activeDays = CountActiveDays(habits, dateFrom, dateTo); + var periodDays = dateTo.DayNumber - dateFrom.DayNumber + 1; + var weeklyConsistency = BuildWeeklyConsistency(weekdayScheduled, weekdayCompleted); + + var topHabits = stats + .OrderByDescending(s => s.CompletionRate) + .ThenByDescending(s => s.CompletedCount) + .Take(MaxHabitStats) + .ToList(); + + var needsAttention = stats + .Where(s => s.CompletionRate < 100) + .OrderBy(s => s.CompletionRate) + .ThenByDescending(s => s.ScheduledCount) + .Take(MaxHabitStats) + .ToList(); + + return new RetrospectiveMetrics( + completionRate, + totalCompletions, + totalScheduled, + activeDays, + periodDays, + currentStreak, + bestStreak, + badHabitSlips, + weeklyConsistency, + topHabits, + needsAttention); + } + + private static void AccumulateWeekdayConsistency( + Habit habit, List scheduledDates, int[] weekdayScheduled, int[] weekdayCompleted) + { + var completedDates = habit.Logs + .Where(l => l.Value > 0) + .Select(l => l.Date) + .ToHashSet(); + + foreach (var date in scheduledDates) + { + var index = WeekdayIndex(date.DayOfWeek); + weekdayScheduled[index]++; + if (completedDates.Contains(date)) + weekdayCompleted[index]++; + } + } + + private static RetrospectiveHabitStat BuildHabitStat(Habit habit, int scheduledCount, int completedCount) => + new( + habit.Title, + habit.Emoji, + Percent(completedCount, scheduledCount), + completedCount, + scheduledCount); + + private static IReadOnlyList BuildWeeklyConsistency(int[] weekdayScheduled, int[] weekdayCompleted) + { + var consistency = new int[7]; + for (var i = 0; i < 7; i++) + consistency[i] = Percent(weekdayCompleted[i], weekdayScheduled[i]); + return consistency; + } + + private static int CountActiveDays(List habits, DateOnly dateFrom, DateOnly dateTo) + { + var activeDates = new HashSet(); + foreach (var habit in habits) + { + foreach (var log in habit.Logs) + { + if (log.Value > 0 && log.Date >= dateFrom && log.Date <= dateTo) + activeDates.Add(log.Date); + } + } + return activeDates.Count; + } + + private static int WeekdayIndex(DayOfWeek day) => Array.IndexOf(WeekOrder, day); + + private static int Percent(int numerator, int denominator) => + denominator > 0 ? (int)Math.Round(100.0 * numerator / denominator) : 0; +} diff --git a/src/Orbit.Domain/Interfaces/IRetrospectiveService.cs b/src/Orbit.Domain/Interfaces/IRetrospectiveService.cs index 302a7187..d5311eb5 100644 --- a/src/Orbit.Domain/Interfaces/IRetrospectiveService.cs +++ b/src/Orbit.Domain/Interfaces/IRetrospectiveService.cs @@ -1,11 +1,12 @@ using Orbit.Domain.Common; using Orbit.Domain.Entities; +using Orbit.Domain.Models; namespace Orbit.Domain.Interfaces; public interface IRetrospectiveService { - Task> GenerateRetrospectiveAsync( + Task> GenerateRetrospectiveAsync( List habits, DateOnly dateFrom, DateOnly dateTo, diff --git a/src/Orbit.Domain/Models/RetrospectiveNarrative.cs b/src/Orbit.Domain/Models/RetrospectiveNarrative.cs new file mode 100644 index 00000000..c2fe4c08 --- /dev/null +++ b/src/Orbit.Domain/Models/RetrospectiveNarrative.cs @@ -0,0 +1,12 @@ +namespace Orbit.Domain.Models; + +/// +/// The four plain-text sections of an AI-generated retrospective. The AI service emits a single +/// labeled document which is parsed into these fields; on a parse miss the whole text lands in +/// and the rest are empty. +/// +public record RetrospectiveNarrative( + string Highlights, + string Missed, + string Trends, + string Suggestion); diff --git a/src/Orbit.Infrastructure/Services/AiRetrospectiveService.cs b/src/Orbit.Infrastructure/Services/AiRetrospectiveService.cs index b4cc1965..b0750169 100644 --- a/src/Orbit.Infrastructure/Services/AiRetrospectiveService.cs +++ b/src/Orbit.Infrastructure/Services/AiRetrospectiveService.cs @@ -1,9 +1,11 @@ +using System.Text; using Microsoft.Extensions.Logging; using Orbit.Application.Common; using Orbit.Application.Habits.Services; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; using Orbit.Infrastructure.AI; namespace Orbit.Infrastructure.Services; @@ -12,7 +14,7 @@ public sealed partial class AiRetrospectiveService( AiCompletionClient aiClient, ILogger logger) : IRetrospectiveService { - public async Task> GenerateRetrospectiveAsync( + public async Task> GenerateRetrospectiveAsync( List habits, DateOnly dateFrom, DateOnly dateTo, @@ -21,7 +23,7 @@ public async Task> GenerateRetrospectiveAsync( CancellationToken cancellationToken = default) { if (habits.Count == 0) - return Result.Failure(ErrorMessages.NoHabitsForPeriod); + return Result.Failure(ErrorMessages.NoHabitsForPeriod); var prompt = BuildRetrospectivePrompt(habits, dateFrom, dateTo, period, language); @@ -37,21 +39,104 @@ public async Task> GenerateRetrospectiveAsync( cancellationToken); if (string.IsNullOrWhiteSpace(text)) - return Result.Failure(ErrorMessages.AiEmptyResponse); + return Result.Failure(ErrorMessages.AiEmptyResponse); var trimmed = AiSummaryService.StripMarkdownFences(text); + var narrative = ParseNarrative(trimmed, language); if (logger.IsEnabled(LogLevel.Information)) LogRetrospectiveGenerated(logger, trimmed.Length); - return Result.Success(trimmed); + return Result.Success(narrative); } catch (Exception ex) when (ex is not OperationCanceledException) { LogRetrospectiveFailed(logger, ex); - return Result.Failure(ErrorMessages.AiRetrospectiveUnavailable); + return Result.Failure(ErrorMessages.AiRetrospectiveUnavailable); } } + private static (string Highlights, string Missed, string Trends, string Suggestion) GetHeadings(string language) + { + return LocaleHelper.IsPortuguese(language) + ? ("Destaques", "Oportunidades Perdidas", "Tendências", "Sugestão") + : ("Highlights", "Missed Opportunities", "Trends", "Suggestion"); + } + + /// + /// Splits the AI document into its four sections by locating each known heading. When the four + /// headings are not all found in order, the whole text is returned as Highlights so the + /// caller always receives the model's output rather than an empty payload. + /// + private static RetrospectiveNarrative ParseNarrative(string text, string language) + { + var (highlights, missed, trends, suggestion) = GetHeadings(language); + var order = new[] { highlights, missed, trends, suggestion }; + + var lines = text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + var sections = new string[4]; + var buffer = new StringBuilder(); + var currentSection = -1; + var matchedSections = 0; + + foreach (var line in lines) + { + var headingIndex = MatchHeadingIndex(line, order); + if (headingIndex >= 0) + { + FlushSection(sections, currentSection, buffer); + currentSection = headingIndex; + matchedSections++; + continue; + } + + if (currentSection >= 0) + buffer.AppendLine(line); + } + + FlushSection(sections, currentSection, buffer); + + if (matchedSections < 4) + return new RetrospectiveNarrative(text.Trim(), string.Empty, string.Empty, string.Empty); + + return new RetrospectiveNarrative(sections[0], sections[1], sections[2], sections[3]); + } + + private static void FlushSection(string[] sections, int sectionIndex, StringBuilder buffer) + { + if (sectionIndex >= 0) + sections[sectionIndex] = buffer.ToString().Trim(); + buffer.Clear(); + } + + private static int MatchHeadingIndex(string line, string[] headings) + { + var normalized = NormalizeHeadingLine(line); + if (normalized.Length == 0) + return -1; + + for (var i = 0; i < headings.Length; i++) + { + if (normalized.Equals(headings[i], StringComparison.OrdinalIgnoreCase)) + return i; + } + return -1; + } + + private static string NormalizeHeadingLine(string line) + { + var trimmed = line.Trim().Trim('#', '*', ' ', '-', ':', '\t'); + + var dotIndex = trimmed.IndexOf('.'); + if (dotIndex > 0 && dotIndex < trimmed.Length - 1 && int.TryParse(trimmed[..dotIndex], out _)) + trimmed = trimmed[(dotIndex + 1)..]; + + var separatorIndex = trimmed.IndexOf("--", StringComparison.Ordinal); + if (separatorIndex >= 0) + trimmed = trimmed[..separatorIndex]; + + return trimmed.Trim('#', '*', ' ', '-', ':', '\t'); + } + private static string BuildRetrospectivePrompt( List habits, DateOnly dateFrom, @@ -61,10 +146,7 @@ private static string BuildRetrospectivePrompt( { var languageName = LocaleHelper.GetAiLanguageName(language); - var isPt = LocaleHelper.IsPortuguese(language); - var (highlightsHeading, missedHeading, trendsHeading, suggestionHeading) = isPt - ? ("Destaques", "Oportunidades Perdidas", "Tend\u00eancias", "Sugest\u00e3o") - : ("Highlights", "Missed Opportunities", "Trends", "Suggestion"); + var (highlightsHeading, missedHeading, trendsHeading, suggestionHeading) = GetHeadings(language); var totalDays = dateTo.DayNumber - dateFrom.DayNumber + 1; var (habitSection, totalCompletions, totalScheduled, badHabitSlips) = diff --git a/tests/Orbit.Application.Tests/Chat/Tools/GetRetrospectiveToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/GetRetrospectiveToolTests.cs index b7653d4a..3d5fbe08 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/GetRetrospectiveToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/GetRetrospectiveToolTests.cs @@ -7,6 +7,7 @@ using Orbit.Application.Habits.Queries; using Orbit.Domain.Common; using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; namespace Orbit.Application.Tests.Chat.Tools; @@ -29,7 +30,7 @@ public GetRetrospectiveToolTests() [Fact] public async Task Success_ReturnsPayload() { - var response = new RetrospectiveResponse("Last week you kept a 5-day streak.", FromCache: false); + var response = CreateResponse("week", "Last week you kept a 5-day streak."); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(response)); @@ -55,7 +56,7 @@ public async Task PayGateFailure_PropagatesError() public async Task MonthPeriod_DerivesThirtyDayRange() { _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(new RetrospectiveResponse("ok", false))); + .Returns(Result.Success(CreateResponse("month", "ok"))); await Execute("""{"period": "month"}"""); @@ -72,7 +73,7 @@ await _mediator.Received(1).Send( public async Task NoPeriod_DefaultsToWeek_AnchoredOnMondayStart() { _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(new RetrospectiveResponse("ok", false))); + .Returns(Result.Success(CreateResponse("week", "ok"))); await Execute("{}"); @@ -89,7 +90,7 @@ public async Task WeekPeriod_SundayStartUser_AnchorsOnSunday() { _userDateService.GetUserWeekStartDayAsync(UserId, Arg.Any()).Returns(0); _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(new RetrospectiveResponse("ok", false))); + .Returns(Result.Success(CreateResponse("week", "ok"))); await Execute("""{"period": "week"}"""); @@ -101,6 +102,13 @@ await _mediator.Received(1).Send( Arg.Any()); } + private static RetrospectiveResponse CreateResponse(string period, string highlights) => + new( + period, + new RetrospectiveMetrics(0, 0, 0, 0, 0, 0, 0, 0, new int[7], [], []), + new RetrospectiveNarrative(highlights, "", "", ""), + FromCache: false); + private async Task Execute(string json) { var args = JsonDocument.Parse(json).RootElement; diff --git a/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs index 14c24c21..fedf943c 100644 --- a/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs @@ -6,6 +6,7 @@ using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; using System.Linq.Expressions; namespace Orbit.Application.Tests.Queries.Habits; @@ -15,78 +16,234 @@ public class GetRetrospectiveQueryHandlerTests private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); private readonly IRetrospectiveService _retroService = Substitute.For(); + private readonly IUserStreakService _streakService = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly GetRetrospectiveQueryHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); - private static readonly DateOnly DateFrom = new(2026, 3, 1); - private static readonly DateOnly DateTo = new(2026, 3, 31); + private static readonly DateOnly DateFrom = new(2026, 3, 2); + private static readonly DateOnly DateTo = new(2026, 3, 8); + + private static readonly RetrospectiveNarrative SampleNarrative = + new("Highlights body", "Missed body", "Trends body", "Suggestion body"); public GetRetrospectiveQueryHandlerTests() { - _handler = new GetRetrospectiveQueryHandler(_habitRepo, _payGate, _retroService, _cache); + _handler = new GetRetrospectiveQueryHandler(_habitRepo, _payGate, _retroService, _streakService, _cache); + _payGate.CanUseRetrospective(UserId, Arg.Any()).Returns(Result.Success()); + _streakService.RecalculateAsync(UserId, Arg.Any(), false) + .Returns(new UserStreakState(4, 9, DateTo)); } - private static Habit CreateTestHabit() + private static Habit CreateDailyHabit(string title = "Test Habit", string? emoji = null, bool isBadHabit = false) { return Habit.Create(new HabitCreateParams( - UserId, "Test Habit", FrequencyUnit.Day, 1, - DueDate: DateFrom)).Value; + UserId, title, FrequencyUnit.Day, 1, + Emoji: emoji, IsBadHabit: isBadHabit, DueDate: DateFrom)).Value; } - [Fact] - public async Task Handle_GeneratesNewRetrospective_WhenNotCached() + private static Habit CreateLoggedHabit(string title = "Test Habit", string? emoji = null) { - _payGate.CanUseRetrospective(UserId, Arg.Any()).Returns(Result.Success()); + var habit = CreateDailyHabit(title, emoji); + habit.Log(DateFrom, advanceDueDate: false); + return habit; + } - var habit = CreateTestHabit(); + private void StubHabits(params Habit[] habits) + { _habitRepo.FindAsync( Arg.Any>>(), Arg.Any, IQueryable>?>(), Arg.Any()) - .Returns(new List { habit }.AsReadOnly()); + .Returns(habits.ToList().AsReadOnly()); + } + private void StubNarrative(RetrospectiveNarrative narrative) + { _retroService.GenerateRetrospectiveAsync( Arg.Any>(), - DateFrom, DateTo, "weekly", "en", + DateFrom, DateTo, "week", "en", Arg.Any()) - .Returns(Result.Success("Retrospective content")); + .Returns(Result.Success(narrative)); + } + + private Task> HandleWeek() => + _handler.Handle(new GetRetrospectiveQuery(UserId, DateFrom, DateTo, "week", "en"), CancellationToken.None); - var query = new GetRetrospectiveQuery(UserId, DateFrom, DateTo, "weekly", "en"); + [Fact] + public async Task Handle_GeneratesNewRetrospective_WhenNotCached() + { + StubHabits(CreateLoggedHabit()); + StubNarrative(SampleNarrative); - var result = await _handler.Handle(query, CancellationToken.None); + var result = await HandleWeek(); result.IsSuccess.Should().BeTrue(); - result.Value.Retrospective.Should().Be("Retrospective content"); + result.Value.Period.Should().Be("week"); + result.Value.Narrative.Should().Be(SampleNarrative); result.Value.FromCache.Should().BeFalse(); } [Fact] - public async Task Handle_ReturnsCachedResult_WhenCached() + public async Task Handle_EchoesStreakFromStreakService() { - _payGate.CanUseRetrospective(UserId, Arg.Any()).Returns(Result.Success()); + StubHabits(CreateLoggedHabit()); + StubNarrative(SampleNarrative); - var habit = CreateTestHabit(); - _habitRepo.FindAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(new List { habit }.AsReadOnly()); + var result = await HandleWeek(); - _retroService.GenerateRetrospectiveAsync( - Arg.Any>(), - DateFrom, DateTo, "weekly", "en", - Arg.Any()) - .Returns(Result.Success("Retro content")); + result.Value.Metrics.CurrentStreak.Should().Be(4); + result.Value.Metrics.BestStreak.Should().Be(9); + } + + [Fact] + public async Task Handle_ComputesPeriodDaysInclusively() + { + StubHabits(CreateLoggedHabit()); + StubNarrative(SampleNarrative); + + var result = await HandleWeek(); + + result.Value.Metrics.PeriodDays.Should().Be(7); + } + + [Fact] + public async Task Handle_ComputesCompletionRateAndActiveDays() + { + var habit = CreateDailyHabit(); + habit.Log(DateFrom, advanceDueDate: false); + habit.Log(DateFrom.AddDays(1), advanceDueDate: false); + habit.Log(DateFrom.AddDays(2), advanceDueDate: false); + StubHabits(habit); + StubNarrative(SampleNarrative); + + var result = await HandleWeek(); + + var metrics = result.Value.Metrics; + metrics.TotalScheduled.Should().Be(7); + metrics.TotalCompletions.Should().Be(3); + metrics.CompletionRate.Should().Be(43); + metrics.ActiveDays.Should().Be(3); + } + + [Fact] + public async Task Handle_WeeklyConsistency_HasSevenMondayFirstValues() + { + var habit = CreateDailyHabit(); + habit.Log(DateFrom, advanceDueDate: false); + habit.Log(DateFrom.AddDays(2), advanceDueDate: false); + StubHabits(habit); + StubNarrative(SampleNarrative); + + var result = await HandleWeek(); + + var consistency = result.Value.Metrics.WeeklyConsistency; + consistency.Should().HaveCount(7); + consistency[0].Should().Be(100); + consistency[1].Should().Be(0); + consistency[2].Should().Be(100); + consistency[6].Should().Be(0); + } + + [Fact] + public async Task Handle_TopHabits_OrderedByHighestRateFirst() + { + var strong = CreateDailyHabit("Strong"); + var weak = CreateDailyHabit("Weak"); + for (var i = 0; i < 7; i++) + strong.Log(DateFrom.AddDays(i), advanceDueDate: false); + weak.Log(DateFrom, advanceDueDate: false); + StubHabits(strong, weak); + StubNarrative(SampleNarrative); + + var result = await HandleWeek(); + + var top = result.Value.Metrics.TopHabits; + top.Should().HaveCount(2); + top[0].Name.Should().Be("Strong"); + top[0].CompletionRate.Should().Be(100); + top[0].Emoji.Should().BeNull(); + top[1].Name.Should().Be("Weak"); + } + + [Fact] + public async Task Handle_NeedsAttention_OrderedByLowestRate_ExcludesPerfectHabits() + { + var perfect = CreateDailyHabit("Perfect"); + var weak = CreateDailyHabit("Weak"); + var middling = CreateDailyHabit("Middling"); + for (var i = 0; i < 7; i++) + perfect.Log(DateFrom.AddDays(i), advanceDueDate: false); + weak.Log(DateFrom, advanceDueDate: false); + for (var i = 0; i < 4; i++) + middling.Log(DateFrom.AddDays(i), advanceDueDate: false); + StubHabits(perfect, weak, middling); + StubNarrative(SampleNarrative); + + var result = await HandleWeek(); + + var needs = result.Value.Metrics.NeedsAttention; + needs.Should().HaveCount(2); + needs.Should().NotContain(s => s.Name == "Perfect"); + needs[0].Name.Should().Be("Weak"); + needs[1].Name.Should().Be("Middling"); + } + + [Fact] + public async Task Handle_BadHabitSlips_CountedSeparately_NotInHabitLists() + { + var bad = CreateDailyHabit("Smoking", isBadHabit: true); + bad.Log(DateFrom, advanceDueDate: false); + bad.Log(DateFrom.AddDays(1), advanceDueDate: false); + StubHabits(bad); + StubNarrative(SampleNarrative); + + var result = await HandleWeek(); + + var metrics = result.Value.Metrics; + metrics.BadHabitSlips.Should().Be(2); + metrics.TotalScheduled.Should().Be(0); + metrics.TopHabits.Should().BeEmpty(); + metrics.NeedsAttention.Should().BeEmpty(); + } - var query = new GetRetrospectiveQuery(UserId, DateFrom, DateTo, "weekly", "en"); + [Fact] + public async Task Handle_IncludesHabitEmoji() + { + StubHabits(CreateLoggedHabit("Run", "🏃")); + StubNarrative(SampleNarrative); + + var result = await HandleWeek(); + + result.Value.Metrics.TopHabits.Should().ContainSingle() + .Which.Emoji.Should().Be("🏃"); + } - await _handler.Handle(query, CancellationToken.None); + [Fact] + public async Task Handle_ReturnsCachedResult_WhenCached() + { + StubHabits(CreateLoggedHabit()); + StubNarrative(SampleNarrative); - var result = await _handler.Handle(query, CancellationToken.None); + await HandleWeek(); + var result = await HandleWeek(); result.IsSuccess.Should().BeTrue(); result.Value.FromCache.Should().BeTrue(); + result.Value.Narrative.Should().Be(SampleNarrative); + } + + [Fact] + public async Task Handle_CacheHit_DoesNotRegenerateNarrative() + { + StubHabits(CreateLoggedHabit()); + StubNarrative(SampleNarrative); + + await HandleWeek(); + await HandleWeek(); + + await _retroService.Received(1).GenerateRetrospectiveAsync( + Arg.Any>(), DateFrom, DateTo, "week", "en", Arg.Any()); } [Fact] @@ -95,9 +252,7 @@ public async Task Handle_PayGateFails_ReturnsFailure() _payGate.CanUseRetrospective(UserId, Arg.Any()) .Returns(Result.Failure("PAY_GATE", "PAY_GATE")); - var query = new GetRetrospectiveQuery(UserId, DateFrom, DateTo, "weekly", "en"); - - var result = await _handler.Handle(query, CancellationToken.None); + var result = await HandleWeek(); result.IsFailure.Should().BeTrue(); } @@ -105,17 +260,9 @@ public async Task Handle_PayGateFails_ReturnsFailure() [Fact] public async Task Handle_NoHabits_ReturnsFailure() { - _payGate.CanUseRetrospective(UserId, Arg.Any()).Returns(Result.Success()); + StubHabits(); - _habitRepo.FindAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(new List().AsReadOnly()); - - var query = new GetRetrospectiveQuery(UserId, DateFrom, DateTo, "weekly", "en"); - - var result = await _handler.Handle(query, CancellationToken.None); + var result = await HandleWeek(); result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("No habits found"); @@ -124,26 +271,28 @@ public async Task Handle_NoHabits_ReturnsFailure() [Fact] public async Task Handle_RetrospectiveServiceFails_ReturnsFailure() { - _payGate.CanUseRetrospective(UserId, Arg.Any()).Returns(Result.Success()); - - var habit = CreateTestHabit(); - _habitRepo.FindAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(new List { habit }.AsReadOnly()); - + StubHabits(CreateLoggedHabit()); _retroService.GenerateRetrospectiveAsync( Arg.Any>(), - DateFrom, DateTo, "weekly", "en", + DateFrom, DateTo, "week", "en", Arg.Any()) - .Returns(Result.Failure("AI service error")); - - var query = new GetRetrospectiveQuery(UserId, DateFrom, DateTo, "weekly", "en"); + .Returns(Result.Failure("AI service error")); - var result = await _handler.Handle(query, CancellationToken.None); + var result = await HandleWeek(); result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("AI service error"); } + + [Fact] + public async Task Handle_HabitsButNoCompletions_ReturnsFailure() + { + StubHabits(CreateDailyHabit()); + StubNarrative(SampleNarrative); + + var result = await HandleWeek(); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("No habits found"); + } } diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/HabitToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/HabitToolsTests.cs index 64a7e785..b0ae8030 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/HabitToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/HabitToolsTests.cs @@ -29,6 +29,9 @@ public HabitToolsTests() _user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } + private static RetrospectiveMetrics EmptyMetrics() => + new(0, 0, 0, 0, 0, 0, 0, 0, new int[7], [], []); + private void StubExecutor(AgentOperationStatus status, string? targetId = null, string? targetName = null, string? policyReason = null, object? payload = null, Guid? pendingOperationId = null) { @@ -602,7 +605,11 @@ public async Task GetRetrospective_Success_ReturnsRetrospective() _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()) .Returns(new DateOnly(2026, 4, 3)); - var response = new RetrospectiveResponse("Great week!", false); + var response = new RetrospectiveResponse( + "week", + EmptyMetrics(), + new RetrospectiveNarrative("Great week!", "", "", ""), + FromCache: false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(response)); diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiRetrospectiveServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiRetrospectiveServiceTests.cs index 1e3b86f8..3e9993b3 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AiRetrospectiveServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AiRetrospectiveServiceTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using Orbit.Domain.Entities; using Orbit.Domain.Enums; +using Orbit.Domain.Models; using Orbit.Infrastructure.Services; namespace Orbit.Infrastructure.Tests.Services; @@ -113,7 +114,8 @@ public void BuildRetrospectivePrompt_CalculatesCorrectTotalDays() var result = InvokeBuildRetrospectivePrompt(habits, from, to, "weekly", "en"); - result.Should().Contain("10 days"); } + result.Should().Contain("10 days"); + } [Fact] public void BuildRetrospectivePrompt_CountsOnlyTopLevelHabits() @@ -224,6 +226,81 @@ public void BuildHabitBreakdown_BadHabitSlipsTrackedSeparately() result.HabitSection.Should().Contain("2 slips"); } + [Fact] + public void ParseNarrative_English_SplitsAllFourSections() + { + var text = + "**Highlights**\nYou nailed Exercise at 100%.\n\n" + + "**Missed Opportunities**\nReading slipped to 20%.\n\n" + + "**Trends**\nMornings are your strongest window.\n\n" + + "**Suggestion**\nSchedule Reading right after lunch."; + + var result = InvokeParseNarrative(text, "en"); + + result.Highlights.Should().Be("You nailed Exercise at 100%."); + result.Missed.Should().Be("Reading slipped to 20%."); + result.Trends.Should().Be("Mornings are your strongest window."); + result.Suggestion.Should().Be("Schedule Reading right after lunch."); + } + + [Fact] + public void ParseNarrative_NumberedHeadings_StripsNumberAndTrailingDescription() + { + var text = + "1. **Highlights** -- what went well\nGreat consistency.\n" + + "2. **Missed Opportunities** -- gaps\nSkipped weekends.\n" + + "3. **Trends** -- patterns\nImproving steadily.\n" + + "4. **Suggestion** -- next step\nKeep the streak alive."; + + var result = InvokeParseNarrative(text, "en"); + + result.Highlights.Should().Be("Great consistency."); + result.Missed.Should().Be("Skipped weekends."); + result.Trends.Should().Be("Improving steadily."); + result.Suggestion.Should().Be("Keep the streak alive."); + } + + [Fact] + public void ParseNarrative_Portuguese_SplitsAllFourSections() + { + var text = + "**Destaques**\nVoce mandou bem.\n\n" + + "**Oportunidades Perdidas**\nFaltou leitura.\n\n" + + "**Tendências**\nMelhorando.\n\n" + + "**Sugestão**\nContinue assim."; + + var result = InvokeParseNarrative(text, "pt-br"); + + result.Highlights.Should().Be("Voce mandou bem."); + result.Missed.Should().Be("Faltou leitura."); + result.Trends.Should().Be("Melhorando."); + result.Suggestion.Should().Be("Continue assim."); + } + + [Fact] + public void ParseNarrative_UnparseableText_FallsBackToHighlights() + { + var text = "Just a single block of prose with no headings at all."; + + var result = InvokeParseNarrative(text, "en"); + + result.Highlights.Should().Be(text); + result.Missed.Should().BeEmpty(); + result.Trends.Should().BeEmpty(); + result.Suggestion.Should().BeEmpty(); + } + + [Fact] + public void ParseNarrative_PartialHeadings_FallsBackToHighlights() + { + var text = "**Highlights**\nGood week.\n**Trends**\nUp and to the right."; + + var result = InvokeParseNarrative(text, "en"); + + result.Highlights.Should().Be(text); + result.Missed.Should().BeEmpty(); + } + private static Habit CreateDailyHabit( string title, bool isBadHabit = false, @@ -264,4 +341,11 @@ private static (string HabitSection, int TotalCompletions, int TotalScheduled, i .GetMethod("BuildHabitBreakdown", PrivateStatic)!; return ((string, int, int, int))method.Invoke(null, [habits, dateFrom, dateTo, totalDays])!; } + + private static RetrospectiveNarrative InvokeParseNarrative(string text, string language) + { + var method = typeof(AiRetrospectiveService) + .GetMethod("ParseNarrative", PrivateStatic)!; + return (RetrospectiveNarrative)method.Invoke(null, [text, language])!; + } }