diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index f57b5922..7e79aff6 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -211,7 +211,6 @@ public async Task GetCalendarMonth( public async Task GetDailySummary( [FromQuery] DateOnly dateFrom, [FromQuery] DateOnly dateTo, - [FromQuery] bool includeOverdue = false, [FromQuery] string language = "en", CancellationToken cancellationToken = default) { @@ -219,7 +218,6 @@ public async Task GetDailySummary( HttpContext.GetUserId(), dateFrom, dateTo, - includeOverdue, language); var result = await mediator.Send(query, cancellationToken); diff --git a/src/Orbit.Api/Mcp/Tools/HabitTools.cs b/src/Orbit.Api/Mcp/Tools/HabitTools.cs index 2720e475..36b95854 100644 --- a/src/Orbit.Api/Mcp/Tools/HabitTools.cs +++ b/src/Orbit.Api/Mcp/Tools/HabitTools.cs @@ -514,7 +514,6 @@ public async Task GetDailySummary( ClaimsPrincipal user, [Description(DateFromDescription)] string dateFrom, [Description(DateToDescription)] string dateTo, - [Description("Include overdue habits")] bool includeOverdue = true, [Description("Language code (en, pt-BR)")] string language = "en", CancellationToken cancellationToken = default) { @@ -523,7 +522,6 @@ public async Task GetDailySummary( userId, McpInputParser.ParseDate(dateFrom, "dateFrom"), McpInputParser.ParseDate(dateTo, "dateTo"), - includeOverdue, language); var result = await mediator.Send(query, cancellationToken); diff --git a/src/Orbit.Application/Chat/Tools/Implementations/GetDailySummaryTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/GetDailySummaryTool.cs index 24dee10f..9d8ae872 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/GetDailySummaryTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/GetDailySummaryTool.cs @@ -20,7 +20,6 @@ public class GetDailySummaryTool(IMediator mediator, IUserDateService userDateSe { date_from = new { type = JsonSchemaTypes.String, description = "Start date (YYYY-MM-DD). Defaults to today." }, date_to = new { type = JsonSchemaTypes.String, description = "End date (YYYY-MM-DD). Defaults to date_from." }, - include_overdue = new { type = JsonSchemaTypes.Boolean, description = "Include overdue habits in the summary. Default: false." }, language = new { type = JsonSchemaTypes.String, description = "Language code for the summary text. Default: 'en'." } }, required = Array.Empty() @@ -31,11 +30,10 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel var today = await userDateService.GetUserTodayAsync(userId, ct); var dateFrom = JsonArgumentParser.ParseDateOnly(args, "date_from") ?? today; var dateTo = JsonArgumentParser.ParseDateOnly(args, "date_to") ?? dateFrom; - var includeOverdue = JsonArgumentParser.GetOptionalBool(args, "include_overdue") ?? false; var language = JsonArgumentParser.GetOptionalString(args, "language") ?? "en"; var result = await mediator.Send( - new GetDailySummaryQuery(userId, dateFrom, dateTo, includeOverdue, language), ct); + new GetDailySummaryQuery(userId, dateFrom, dateTo, language), ct); return result.IsSuccess ? new ToolResult(true, Payload: result.Value) diff --git a/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs b/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs index 41a5aa7f..7b82d308 100644 --- a/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs @@ -14,7 +14,6 @@ public record GetDailySummaryQuery( Guid UserId, DateOnly DateFrom, DateOnly DateTo, - bool IncludeOverdue, string Language) : IRequest>; public class GetDailySummaryQueryHandler( @@ -81,7 +80,6 @@ public async Task> Handle( summaryHabits, request.DateFrom, request.DateTo, - request.IncludeOverdue, effectiveLanguage, currentLocalTime, cancellationToken); diff --git a/src/Orbit.Domain/Interfaces/ISummaryService.cs b/src/Orbit.Domain/Interfaces/ISummaryService.cs index 1247394a..af26ca21 100644 --- a/src/Orbit.Domain/Interfaces/ISummaryService.cs +++ b/src/Orbit.Domain/Interfaces/ISummaryService.cs @@ -9,7 +9,6 @@ Task> GenerateSummaryAsync( IEnumerable allHabits, DateOnly dateFrom, DateOnly dateTo, - bool includeOverdue, string language, TimeOnly? currentLocalTime, CancellationToken cancellationToken = default); diff --git a/src/Orbit.Infrastructure/AI/AiCompletionClient.cs b/src/Orbit.Infrastructure/AI/AiCompletionClient.cs index 643eb4b6..01f0dff9 100644 --- a/src/Orbit.Infrastructure/AI/AiCompletionClient.cs +++ b/src/Orbit.Infrastructure/AI/AiCompletionClient.cs @@ -50,7 +50,8 @@ public AiCompletionClient(IOptions options, ILogger { @@ -63,6 +64,9 @@ public AiCompletionClient(IOptions options, ILogger> GenerateSummaryAsync( IEnumerable allHabits, DateOnly dateFrom, DateOnly dateTo, - bool includeOverdue, string language, TimeOnly? currentLocalTime, CancellationToken cancellationToken = default) @@ -40,15 +39,7 @@ public async Task> GenerateSummaryAsync( var scheduledHabits = scheduledTopLevel.Concat(children).ToList(); - var overdueHabits = includeOverdue - ? habitList - .Where(h => !h.IsCompleted - && h.DueDate < dateFrom - && !HasSkipLogInRange(h, dateFrom, dateTo)) - .ToList() - : []; - - var prompt = BuildSummaryPrompt(scheduledHabits, overdueHabits, dateFrom, dateTo, language, currentLocalTime); + var prompt = BuildSummaryPrompt(scheduledHabits, dateFrom, dateTo, language, currentLocalTime); if (logger.IsEnabled(LogLevel.Information)) LogGeneratingDailySummary(logger, dateFrom, language); @@ -56,10 +47,11 @@ public async Task> GenerateSummaryAsync( try { var text = await aiClient.CompleteTextAsync( - "You are a friendly habit coach. Write short daily briefings.", + "You are Astra, a perceptive, warm close friend who knows the person well. You notice and celebrate what they have already done, and you stay easy and unpushy about what is left. You never sound corporate, clinical, or like a coach reading a checklist. You write plain text only -- no markdown, bullets, headings, emoji, or JSON -- with no greeting and no sign-off, only in the language you are told to use.", prompt, temperature: 0.7, - cancellationToken); + cancellationToken, + maxOutputTokens: 200); if (string.IsNullOrWhiteSpace(text)) return Result.Failure("AI returned empty response"); @@ -79,7 +71,6 @@ public async Task> GenerateSummaryAsync( private static string BuildSummaryPrompt( List scheduledHabits, - List overdueHabits, DateOnly date, DateOnly dateTo, string language, @@ -87,11 +78,7 @@ private static string BuildSummaryPrompt( { var languageName = LocaleHelper.GetAiLanguageName(language); - var habitSection = BuildHabitSection(scheduledHabits, date, dateTo, currentLocalTime); - - var overdueSection = overdueHabits.Count > 0 - ? string.Join("\n", overdueHabits.Select(h => $"- {h.Title}")) - : "(none)"; + var habitSection = BuildHabitSection(scheduledHabits, date, dateTo); var totalCount = scheduledHabits.Count; var doneTotal = scheduledHabits.Count(h => IsDoneInRange(h, date, dateTo)); @@ -99,46 +86,43 @@ private static string BuildSummaryPrompt( return $""" Date: {date:MMMM d, yyyy} - Current local time: {timeContext} + Current part of day: {timeContext} Progress: {doneTotal}/{totalCount} habits completed Today's habits: {habitSection} - Overdue from previous days: - {overdueSection} + Write a short message to this person about their day. Rules: - - Write 2-3 short sentences max, like a supportive friend texting you - - Weave habits into natural sentences about the DAY, don't just list habit names - - BAD: "Today you have Yoga, Morning Routine, and Guitar Playing." - - GOOD: "A good day to stretch out with some yoga and get creative on the guitar." + - LEAD with a specific, genuine acknowledgment of what they have ALREADY completed today -- name the activity naturally, don't just say "good job" + - THEN, gently point at one or two of the still-pending habits as easy next moves -- never list everything, never frame it as a checklist, never guilt-trip + - If nothing is done yet, stay warm and forward-looking; do NOT imply they are behind or failing - Describe the ACTIVITY naturally, don't just parrot the exact habit title - - If some habits are done, briefly acknowledge progress - - If there are overdue habits, gently nudge without guilt-tripping - - Use the current local time to decide what is still relevant now - - If it is evening or night, do NOT frame earlier morning habits as a way to start the day - - When earlier-day habits are still pending, mention them only as optional catch-up or closure, then focus on habits that fit the current or upcoming part of the day - - Keep it casual, warm, and concise -- not corporate or overly enthusiastic + - BAD: "You have Yoga, Morning Routine, and Guitar Playing left." + - GOOD: "Nice work getting your run in -- some guitar later could be a great way to unwind." + - Keep it to 2-3 sentences, warm and close, like a friend who actually knows you -- never corporate or coach-like + - This message is shown for the WHOLE current part of the day, so it must read correctly whether they see it at the start or the end of that window + - Treat the time of day as a broad window, not an exact moment; never imply a precise instant + - Do NOT use phrases like "right now", "just woke up", "now that the afternoon is here", "as the day begins", "earlier today", or "upcoming later today" - Do NOT use markdown, bullet points, emojis, or JSON - Do NOT mention the date explicitly - Write ONLY in {languageName} - - No greeting like "good morning", no sign-off -- just the briefing + - No greeting like "good morning", no sign-off -- just the message """; } private static string BuildHabitSection( List scheduledHabits, DateOnly dateFrom, - DateOnly dateTo, - TimeOnly? currentLocalTime) + DateOnly dateTo) { var habitLines = new List(); foreach (var habit in scheduledHabits.Where(h => h.ParentHabitId is null)) { var status = IsDoneInRange(habit, dateFrom, dateTo) ? "done" : "pending"; - var timing = DescribeTiming(habit, currentLocalTime); + var timing = DescribeTiming(habit); var children = scheduledHabits.Where(h => h.ParentHabitId == habit.Id).ToList(); if (children.Count > 0) @@ -146,7 +130,7 @@ private static string BuildHabitSection( var doneCount = children.Count(c => IsDoneInRange(c, dateFrom, dateTo)); habitLines.Add($"- {habit.Title} ({status}, {doneCount}/{children.Count} sub-tasks done) [{timing}]"); foreach (var child in children) - habitLines.Add($" - {child.Title} ({(IsDoneInRange(child, dateFrom, dateTo) ? "done" : "pending")}) [{DescribeTiming(child, currentLocalTime)}]"); + habitLines.Add($" - {child.Title} ({(IsDoneInRange(child, dateFrom, dateTo) ? "done" : "pending")}) [{DescribeTiming(child)}]"); } else { @@ -166,36 +150,31 @@ private static bool HasCompletedLogInRange(Habit habit, DateOnly dateFrom, DateO private static bool IsDoneInRange(Habit habit, DateOnly dateFrom, DateOnly dateTo) => habit.IsCompleted || HasCompletedLogInRange(habit, dateFrom, dateTo); - private static string BuildTimeContext(TimeOnly? currentLocalTime) => - currentLocalTime.HasValue - ? $"{currentLocalTime.Value:HH\\:mm} ({ResolveDayPeriod(currentLocalTime.Value)})" - : "not provided"; - - private static string DescribeTiming(Habit habit, TimeOnly? currentLocalTime) + private static string BuildTimeContext(TimeOnly? currentLocalTime) { - var dueDescription = habit.DueTime.HasValue - ? $"due {habit.DueTime.Value:HH\\:mm}" - : InferTitleTimePeriod(habit.Title); - if (!currentLocalTime.HasValue) - return dueDescription ?? "no specific time"; + return "not provided"; - var relation = ResolveTimeRelation(habit, currentLocalTime.Value); - return dueDescription is null ? relation : $"{dueDescription}, {relation}"; + var period = ResolveDayPeriod(currentLocalTime.Value); + return $"{period.ToString().ToLowerInvariant()} ({PeriodRange(period)})"; } - private static string ResolveTimeRelation(Habit habit, TimeOnly currentLocalTime) + private static string PeriodRange(DayPeriod period) => period switch { - if (habit.DueTime.HasValue) - return habit.DueTime.Value < currentLocalTime ? "earlier today" : "upcoming later today"; + DayPeriod.Morning => "~5am-11am", + DayPeriod.Afternoon => "~11am-5pm", + DayPeriod.Evening => "~5pm-9pm", + DayPeriod.Night => "~9pm-late", + _ => "~5am-11am" + }; - var inferredPeriod = InferTitleDayPeriod(habit.Title); - if (inferredPeriod is null) - return "no specific time"; + private static string DescribeTiming(Habit habit) + { + var dueDescription = habit.DueTime.HasValue + ? $"due {habit.DueTime.Value:HH\\:mm}" + : InferTitleTimePeriod(habit.Title); - return PeriodRank(inferredPeriod.Value) < PeriodRank(ResolveDayPeriod(currentLocalTime)) - ? "earlier today" - : "fits now or later today"; + return dueDescription ?? "no specific time"; } private static string? InferTitleTimePeriod(string title) @@ -235,15 +214,6 @@ private static DayPeriod ResolveDayPeriod(TimeOnly time) return DayPeriod.Night; } - private static int PeriodRank(DayPeriod period) => period switch - { - DayPeriod.Morning => 0, - DayPeriod.Afternoon => 1, - DayPeriod.Evening => 2, - DayPeriod.Night => 3, - _ => 0 - }; - private enum DayPeriod { Morning, diff --git a/tests/Orbit.Application.Tests/Chat/Tools/GetDailySummaryToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/GetDailySummaryToolTests.cs index 8b88a2ed..9d4aaa0a 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/GetDailySummaryToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/GetDailySummaryToolTests.cs @@ -63,7 +63,6 @@ await _mediator.Received(1).Send( q.UserId == UserId && q.DateFrom == Today && q.DateTo == Today && - q.IncludeOverdue == false && q.Language == "en"), Arg.Any()); } diff --git a/tests/Orbit.Application.Tests/Queries/Habits/GetDailySummaryQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/GetDailySummaryQueryHandlerTests.cs index 069518c7..96d0154b 100644 --- a/tests/Orbit.Application.Tests/Queries/Habits/GetDailySummaryQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Habits/GetDailySummaryQueryHandlerTests.cs @@ -47,12 +47,12 @@ public async Task Handle_GeneratesNewSummary_WhenNotCached() _summaryService.GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "en", + Today, Today, "en", Arg.Any(), Arg.Any()) .Returns(Result.Success("Test summary content")); - var query = new GetDailySummaryQuery(UserId, Today, Today, false, "en"); + var query = new GetDailySummaryQuery(UserId, Today, Today, "en"); var result = await _handler.Handle(query, CancellationToken.None); @@ -76,12 +76,12 @@ public async Task Handle_ReturnsCachedSummary_WhenCached() _summaryService.GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "en", + Today, Today, "en", Arg.Any(), Arg.Any()) .Returns(Result.Success("First call summary")); - var query = new GetDailySummaryQuery(UserId, Today, Today, false, "en"); + var query = new GetDailySummaryQuery(UserId, Today, Today, "en"); // First call populates cache await _handler.Handle(query, CancellationToken.None); @@ -114,12 +114,12 @@ public async Task Handle_ExcludesHabitsSkippedInRequestedRange() _summaryService.GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "en", + Today, Today, "en", Arg.Any(), Arg.Any()) .Returns(Result.Success("Summary")); - var query = new GetDailySummaryQuery(UserId, Today, Today, false, "en"); + var query = new GetDailySummaryQuery(UserId, Today, Today, "en"); var result = await _handler.Handle(query, CancellationToken.None); @@ -127,7 +127,7 @@ public async Task Handle_ExcludesHabitsSkippedInRequestedRange() await _summaryService.Received(1).GenerateSummaryAsync( Arg.Is>(habits => habits.Select(h => h.Title).SequenceEqual(new[] { "Read" })), - Today, Today, false, "en", + Today, Today, "en", Arg.Any(), Arg.Any()); } @@ -138,7 +138,7 @@ public async Task Handle_UserNotFound_ReturnsFailure() _payGate.CanUseDailySummary(UserId, Arg.Any()).Returns(Result.Success()); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns((User?)null); - var query = new GetDailySummaryQuery(UserId, Today, Today, false, "en"); + var query = new GetDailySummaryQuery(UserId, Today, Today, "en"); var result = await _handler.Handle(query, CancellationToken.None); @@ -153,7 +153,7 @@ public async Task Handle_PayGateFails_ReturnsFailure() _payGate.CanUseDailySummary(UserId, Arg.Any()) .Returns(Result.Failure("PAY_GATE", "PAY_GATE")); - var query = new GetDailySummaryQuery(UserId, Today, Today, false, "en"); + var query = new GetDailySummaryQuery(UserId, Today, Today, "en"); var result = await _handler.Handle(query, CancellationToken.None); @@ -169,7 +169,7 @@ public async Task Handle_AiSummaryDisabled_ReturnsFailure() _payGate.CanUseDailySummary(UserId, Arg.Any()).Returns(Result.Success()); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); - var query = new GetDailySummaryQuery(UserId, Today, Today, false, "en"); + var query = new GetDailySummaryQuery(UserId, Today, Today, "en"); var result = await _handler.Handle(query, CancellationToken.None); @@ -192,12 +192,12 @@ public async Task Handle_SummaryServiceFails_ReturnsFailure() _summaryService.GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "en", + Today, Today, "en", Arg.Any(), Arg.Any()) .Returns(Result.Failure("AI service unavailable")); - var query = new GetDailySummaryQuery(UserId, Today, Today, false, "en"); + var query = new GetDailySummaryQuery(UserId, Today, Today, "en"); var result = await _handler.Handle(query, CancellationToken.None); @@ -221,25 +221,25 @@ public async Task Handle_UsesUserProfileLanguage_WhenRequestLanguageDiffers() _summaryService.GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "pt-BR", + Today, Today, "pt-BR", Arg.Any(), Arg.Any()) .Returns(Result.Success("Resumo em portugues")); // Request supplies "en" but the DB-persisted profile language is "pt-BR". - var query = new GetDailySummaryQuery(UserId, Today, Today, false, "en"); + var query = new GetDailySummaryQuery(UserId, Today, Today, "en"); var result = await _handler.Handle(query, CancellationToken.None); result.IsSuccess.Should().BeTrue(); await _summaryService.Received(1).GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "pt-BR", + Today, Today, "pt-BR", Arg.Any(), Arg.Any()); await _summaryService.DidNotReceive().GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "en", + Today, Today, "en", Arg.Any(), Arg.Any()); } @@ -259,19 +259,19 @@ public async Task Handle_FallsBackToRequestLanguage_WhenUserLanguageEmpty() _summaryService.GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "pt-BR", + Today, Today, "pt-BR", Arg.Any(), Arg.Any()) .Returns(Result.Success("Resumo")); - var query = new GetDailySummaryQuery(UserId, Today, Today, false, "pt-BR"); + var query = new GetDailySummaryQuery(UserId, Today, Today, "pt-BR"); var result = await _handler.Handle(query, CancellationToken.None); result.IsSuccess.Should().BeTrue(); await _summaryService.Received(1).GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "pt-BR", + Today, Today, "pt-BR", Arg.Any(), Arg.Any()); } @@ -291,19 +291,19 @@ public async Task Handle_FallsBackToEnglish_WhenBothEmpty() _summaryService.GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "en", + Today, Today, "en", Arg.Any(), Arg.Any()) .Returns(Result.Success("Summary")); - var query = new GetDailySummaryQuery(UserId, Today, Today, false, ""); + var query = new GetDailySummaryQuery(UserId, Today, Today, ""); var result = await _handler.Handle(query, CancellationToken.None); result.IsSuccess.Should().BeTrue(); await _summaryService.Received(1).GenerateSummaryAsync( Arg.Any>(), - Today, Today, false, "en", + Today, Today, "en", Arg.Any(), Arg.Any()); } diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiSummaryServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiSummaryServiceTests.cs index 85810909..3eb1582c 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AiSummaryServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AiSummaryServiceTests.cs @@ -18,6 +18,16 @@ public class AiSummaryServiceTests private static readonly BindingFlags PrivateStatic = BindingFlags.NonPublic | BindingFlags.Static; + private static readonly string[] BannedInstantPhrases = + [ + "right now", + "just woke up", + "now that the afternoon is here", + "as the day begins", + "earlier today", + "upcoming later today", + ]; + // ── StripMarkdownFences ────────────────────────────────────────── [Fact] @@ -80,7 +90,7 @@ public void BuildSummaryPrompt_English_ContainsEnglishLanguageName() { var habits = new List { CreateHabit("Yoga") }; - var result = InvokeBuildSummaryPrompt(habits, [], Today, "en"); + var result = InvokeBuildSummaryPrompt(habits, Today, "en"); result.Should().Contain("English"); result.Should().Contain("Yoga"); @@ -91,7 +101,7 @@ public void BuildSummaryPrompt_Portuguese_ContainsBrazilianPortuguese() { var habits = new List { CreateHabit("Leitura") }; - var result = InvokeBuildSummaryPrompt(habits, [], Today, "pt-br"); + var result = InvokeBuildSummaryPrompt(habits, Today, "pt-br"); result.Should().Contain("Brazilian Portuguese"); } @@ -101,7 +111,7 @@ public void BuildSummaryPrompt_PtShort_AlsoMapsToPortuguese() { var habits = new List { CreateHabit("Read") }; - var result = InvokeBuildSummaryPrompt(habits, [], Today, "pt"); + var result = InvokeBuildSummaryPrompt(habits, Today, "pt"); result.Should().Contain("Brazilian Portuguese"); } @@ -112,33 +122,11 @@ public void BuildSummaryPrompt_IncludesDateInOutput() var habits = new List { CreateHabit("Walk") }; var date = new DateOnly(2025, 12, 25); - var result = InvokeBuildSummaryPrompt(habits, [], date, "en"); + var result = InvokeBuildSummaryPrompt(habits, date, "en"); result.Should().Contain("December 25, 2025"); } - [Fact] - public void BuildSummaryPrompt_WithOverdueHabits_IncludesOverdueSection() - { - var scheduled = new List { CreateHabit("Exercise") }; - var overdue = new List { CreateHabit("Meditation") }; - - var result = InvokeBuildSummaryPrompt(scheduled, overdue, Today, "en"); - - result.Should().Contain("Meditation"); - result.Should().NotContain("(none)"); - } - - [Fact] - public void BuildSummaryPrompt_NoOverdueHabits_ShowsNone() - { - var scheduled = new List { CreateHabit("Exercise") }; - - var result = InvokeBuildSummaryPrompt(scheduled, [], Today, "en"); - - result.Should().Contain("(none)"); - } - [Fact] public void BuildSummaryPrompt_ShowsProgressCount() { @@ -149,7 +137,7 @@ public void BuildSummaryPrompt_ShowsProgressCount() CreateHabit("C"), }; - var result = InvokeBuildSummaryPrompt(habits, [], Today, "en"); + var result = InvokeBuildSummaryPrompt(habits, Today, "en"); result.Should().Contain("0/3 habits completed"); } @@ -200,34 +188,26 @@ public void BuildHabitSection_ChildrenOnly_ReturnsNoHabitsScheduled() result.Should().Be("(no habits scheduled)"); } - // ── BuildSummaryPrompt additional edge cases ── - [Fact] - public void BuildSummaryPrompt_UnknownLanguage_DefaultsToEnglish() + public void BuildHabitSection_HabitWithDueTime_ShowsAbsoluteDueTime() { - var habits = new List { CreateHabit("Test") }; + var habits = new List { CreateHabit("Workout", dueTime: new TimeOnly(14, 0)) }; - var result = InvokeBuildSummaryPrompt(habits, [], Today, "fr"); + var result = InvokeBuildHabitSection(habits); - result.Should().Contain("English"); + result.Should().Contain("due 14:00"); } + // ── BuildSummaryPrompt additional edge cases ── + [Fact] - public void BuildSummaryPrompt_MultipleOverdueHabits_AllListed() + public void BuildSummaryPrompt_UnknownLanguage_DefaultsToEnglish() { - var scheduled = new List { CreateHabit("Active") }; - var overdue = new List - { - CreateHabit("Overdue1"), - CreateHabit("Overdue2"), - CreateHabit("Overdue3"), - }; + var habits = new List { CreateHabit("Test") }; - var result = InvokeBuildSummaryPrompt(scheduled, overdue, Today, "en"); + var result = InvokeBuildSummaryPrompt(habits, Today, "fr"); - result.Should().Contain("Overdue1"); - result.Should().Contain("Overdue2"); - result.Should().Contain("Overdue3"); + result.Should().Contain("English"); } // ── BuildSummaryPrompt with various habit types ── @@ -235,33 +215,77 @@ public void BuildSummaryPrompt_MultipleOverdueHabits_AllListed() [Fact] public void BuildSummaryPrompt_EmptyHabitList_ShowsNoHabitsScheduled() { - var result = InvokeBuildSummaryPrompt([], [], Today, "en"); + var result = InvokeBuildSummaryPrompt([], Today, "en"); result.Should().Contain("(no habits scheduled)"); result.Should().Contain("0/0 habits completed"); } [Fact] - public void BuildSummaryPrompt_ContainsRulesSection() + public void BuildSummaryPrompt_EstablishesCloseFriendPersonaAndLeadWithDone() { var habits = new List { CreateHabit("Walk") }; - var result = InvokeBuildSummaryPrompt(habits, [], Today, "en"); + var result = InvokeBuildSummaryPrompt(habits, Today, "en"); result.Should().Contain("Rules:"); - result.Should().Contain("2-3 short sentences"); + result.Should().Contain("LEAD with a specific"); + result.Should().Contain("ALREADY completed today"); + result.Should().Contain("friend who actually knows you"); result.Should().Contain("Do NOT use markdown"); } [Fact] - public void BuildSummaryPrompt_EveningContext_TellsAiNotToFrameMorningHabitsAsStartOfDay() + public void BuildSummaryPrompt_ContextDoesNotSeedBannedInstantPhrases() { - var habits = new List { CreateHabit("Morning routine") }; + var habits = new List + { + CreateHabit("Morning routine"), + CreateHabit("Evening walk", dueTime: new TimeOnly(20, 0)), + }; - var result = InvokeBuildSummaryPrompt(habits, [], Today, "en", new TimeOnly(19, 0)); + var result = InvokeBuildSummaryPrompt(habits, Today, "en", new TimeOnly(19, 0)); - result.Should().Contain("Current local time: 19:00 (Evening)"); - result.Should().Contain("Morning routine (pending) [title suggests Morning, earlier today]"); - result.Should().Contain("do NOT frame earlier morning habits as a way to start the day"); + // The rules section deliberately NAMES these phrases to ban them; the model-facing + // context above it (date, time window, habit lines) must never seed them. + var context = result[..result.IndexOf("Rules:", StringComparison.Ordinal)]; + + foreach (var phrase in BannedInstantPhrases) + context.Should().NotContain(phrase, $"the prompt context must not seed the instant-phrase \"{phrase}\""); + } + + [Fact] + public void BuildSummaryPrompt_RulesBanEveryInstantPhrase() + { + var habits = new List { CreateHabit("Walk") }; + + var result = InvokeBuildSummaryPrompt(habits, Today, "en", new TimeOnly(14, 0)); + + var rules = result[result.IndexOf("Rules:", StringComparison.Ordinal)..]; + foreach (var phrase in BannedInstantPhrases) + rules.Should().Contain(phrase, $"the rules must explicitly ban \"{phrase}\""); + } + + [Fact] + public void BuildSummaryPrompt_PeriodRobustTimeContext_UsesRangeNotExactMinute() + { + var habits = new List { CreateHabit("Evening walk") }; + + var result = InvokeBuildSummaryPrompt(habits, Today, "en", new TimeOnly(19, 0)); + + result.Should().Contain("Current part of day: evening (~5pm-9pm)"); + result.Should().NotContain("19:00"); + } + + [Fact] + public void BuildSummaryPrompt_PeriodRobustTimeContext_StartOfPeriodMatchesEnd() + { + var habits = new List { CreateHabit("Afternoon focus") }; + + var startOfAfternoon = InvokeBuildSummaryPrompt(habits, Today, "en", new TimeOnly(11, 0)); + var endOfAfternoon = InvokeBuildSummaryPrompt(habits, Today, "en", new TimeOnly(16, 59)); + + startOfAfternoon.Should().Contain("afternoon (~11am-5pm)"); + endOfAfternoon.Should().Contain("afternoon (~11am-5pm)"); } [Fact] @@ -270,7 +294,7 @@ public void BuildSummaryPrompt_UpperCaseLanguage_StillMapsCorrectly() var habits = new List { CreateHabit("Test") }; // "PT-BR" should still map since the switch lowercases it - var result = InvokeBuildSummaryPrompt(habits, [], Today, "PT-BR"); + var result = InvokeBuildSummaryPrompt(habits, Today, "PT-BR"); result.Should().Contain("Brazilian Portuguese"); } @@ -279,7 +303,7 @@ public void BuildSummaryPrompt_UpperCaseLanguage_StillMapsCorrectly() public void BuildSummaryPrompt_EmptyLanguageString_DefaultsToEnglish() { var habits = new List { CreateHabit("Test") }; - var result = InvokeBuildSummaryPrompt(habits, [], Today, ""); + var result = InvokeBuildSummaryPrompt(habits, Today, ""); result.Should().Contain("English"); } @@ -322,20 +346,6 @@ public void BuildHabitSection_ParentWithMultipleChildrenGroups_ShowsAllHierarchi result.Should().Contain("Math (pending)"); } - // ── BuildSummaryPrompt with overdue section only ── - - [Fact] - public void BuildSummaryPrompt_OnlyOverdueHabits_ScheduledSectionEmpty() - { - var overdue = new List { CreateHabit("Overdue Task") }; - - var result = InvokeBuildSummaryPrompt([], overdue, Today, "en"); - - result.Should().Contain("(no habits scheduled)"); - result.Should().Contain("Overdue Task"); - result.Should().NotContain("(none)"); - } - // ── StripMarkdownFences edge cases ── [Fact] @@ -379,7 +389,7 @@ public void BuildSummaryPrompt_NewYearsDay_FormatsDateCorrectly() var habits = new List { CreateHabit("Celebrate") }; var date = new DateOnly(2026, 1, 1); - var result = InvokeBuildSummaryPrompt(habits, [], date, "en"); + var result = InvokeBuildSummaryPrompt(habits, date, "en"); result.Should().Contain("January 1, 2026"); } @@ -398,7 +408,7 @@ public void BuildSummaryPrompt_AllHabitsPresent_ShowsCorrectTotal() CreateHabit("E"), }; - var result = InvokeBuildSummaryPrompt(habits, [], Today, "en"); + var result = InvokeBuildSummaryPrompt(habits, Today, "en"); result.Should().Contain("0/5 habits completed"); } @@ -421,7 +431,8 @@ public void BuildHabitSection_Children_IndentedWithTwoSpaces() private static Habit CreateHabit( string title, Guid? parentId = null, - DateOnly? dueDate = null) + DateOnly? dueDate = null, + TimeOnly? dueTime = null) { return Habit.Create(new HabitCreateParams( ValidUserId, @@ -429,25 +440,25 @@ private static Habit CreateHabit( FrequencyUnit.Day, 1, DueDate: dueDate ?? Today, + DueTime: dueTime, ParentHabitId: parentId)).Value; } private static string InvokeBuildSummaryPrompt( List scheduledHabits, - List overdueHabits, DateOnly date, string language, TimeOnly? currentLocalTime = null) { var method = typeof(AiSummaryService) .GetMethod("BuildSummaryPrompt", PrivateStatic)!; - return (string)method.Invoke(null, [scheduledHabits, overdueHabits, date, date, language, currentLocalTime])!; + return (string)method.Invoke(null, [scheduledHabits, date, date, language, currentLocalTime])!; } private static string InvokeBuildHabitSection(List scheduledHabits) { var method = typeof(AiSummaryService) .GetMethod("BuildHabitSection", PrivateStatic)!; - return (string)method.Invoke(null, [scheduledHabits, Today, Today, null])!; + return (string)method.Invoke(null, [scheduledHabits, Today, Today])!; } }