Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/Orbit.Api/Controllers/HabitsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,15 +211,13 @@ public async Task<IActionResult> GetCalendarMonth(
public async Task<IActionResult> GetDailySummary(
[FromQuery] DateOnly dateFrom,
[FromQuery] DateOnly dateTo,
[FromQuery] bool includeOverdue = false,
[FromQuery] string language = "en",
CancellationToken cancellationToken = default)
{
var query = new GetDailySummaryQuery(
HttpContext.GetUserId(),
dateFrom,
dateTo,
includeOverdue,
language);

var result = await mediator.Send(query, cancellationToken);
Expand Down
2 changes: 0 additions & 2 deletions src/Orbit.Api/Mcp/Tools/HabitTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,6 @@ public async Task<string> 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)
{
Expand All @@ -523,7 +522,6 @@ public async Task<string> GetDailySummary(
userId,
McpInputParser.ParseDate(dateFrom, "dateFrom"),
McpInputParser.ParseDate(dateTo, "dateTo"),
includeOverdue,
language);

var result = await mediator.Send(query, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
Expand All @@ -31,11 +30,10 @@ public async Task<ToolResult> 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)
Expand Down
2 changes: 0 additions & 2 deletions src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ public record GetDailySummaryQuery(
Guid UserId,
DateOnly DateFrom,
DateOnly DateTo,
bool IncludeOverdue,
string Language) : IRequest<Result<DailySummaryResponse>>;

public class GetDailySummaryQueryHandler(
Expand Down Expand Up @@ -81,7 +80,6 @@ public async Task<Result<DailySummaryResponse>> Handle(
summaryHabits,
request.DateFrom,
request.DateTo,
request.IncludeOverdue,
effectiveLanguage,
currentLocalTime,
cancellationToken);
Expand Down
1 change: 0 additions & 1 deletion src/Orbit.Domain/Interfaces/ISummaryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ Task<Result<string>> GenerateSummaryAsync(
IEnumerable<Habit> allHabits,
DateOnly dateFrom,
DateOnly dateTo,
bool includeOverdue,
string language,
TimeOnly? currentLocalTime,
CancellationToken cancellationToken = default);
Expand Down
6 changes: 5 additions & 1 deletion src/Orbit.Infrastructure/AI/AiCompletionClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ public AiCompletionClient(IOptions<AiSettings> options, ILogger<AiCompletionClie
string systemPrompt,
string userPrompt,
double temperature = 0.7,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
int? maxOutputTokens = null)
{
var messages = new List<ChatMessage>
{
Expand All @@ -63,6 +64,9 @@ public AiCompletionClient(IOptions<AiSettings> options, ILogger<AiCompletionClie
Temperature = (float)temperature
};

if (maxOutputTokens is int max)
options.MaxOutputTokenCount = max;

LogCallingTextCompletion(_logger);

var completion = await _chatClient.CompleteChatAsync(messages, options, cancellationToken);
Expand Down
104 changes: 37 additions & 67 deletions src/Orbit.Infrastructure/Services/AiSummaryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ public async Task<Result<string>> GenerateSummaryAsync(
IEnumerable<Habit> allHabits,
DateOnly dateFrom,
DateOnly dateTo,
bool includeOverdue,
string language,
TimeOnly? currentLocalTime,
CancellationToken cancellationToken = default)
Expand All @@ -40,26 +39,19 @@ public async Task<Result<string>> 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);

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<string>("AI returned empty response");
Expand All @@ -79,74 +71,66 @@ public async Task<Result<string>> GenerateSummaryAsync(

private static string BuildSummaryPrompt(
List<Habit> scheduledHabits,
List<Habit> overdueHabits,
DateOnly date,
DateOnly dateTo,
string language,
TimeOnly? currentLocalTime)
{
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));
var timeContext = BuildTimeContext(currentLocalTime);

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<Habit> scheduledHabits,
DateOnly dateFrom,
DateOnly dateTo,
TimeOnly? currentLocalTime)
DateOnly dateTo)
{
var habitLines = new List<string>();

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)
{
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
{
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CancellationToken>());
}
Expand Down
Loading
Loading