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
43 changes: 43 additions & 0 deletions src/Orbit.Application/Chat/SuggestBreakdownAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Orbit.Application.Common.Attributes;

namespace Orbit.Application.Chat;

/// <summary>
/// Metadata-only class for the SuggestBreakdown AI action.
/// This action has no MediatR command -- it passes through as a suggestion in ProcessUserChatCommand.
/// The [AiAction] and [AiField] attributes are discovered by ActionDiscoveryService for prompt generation.
/// </summary>
[AiAction(
"SuggestBreakdown",
"""**Suggest habit breakdowns** for complex goals (e.g., "help me get fit" -> suggests Exercise parent with Running, Stretching, Gym sub-habits)""",
"""
- User asks to "break down", "decompose", "help me plan", or asks for suggestions for a complex goal
- You want to PROPOSE a habit based on something the user mentioned casually (e.g., "I like gaming" -> suggest a weekly gaming habit)
- User is vague and you want to offer options before committing
- SuggestBreakdown works for SINGLE habits too -- just put one item in suggestedSubHabits. The user gets accept/decline/edit buttons.
- SuggestBreakdown NEVER creates anything - it only proposes. The user must confirm before creation.
""",
DisplayOrder = 15)]
[AiExample(
"Help me get fit",
"""{ "actions": [{ "type": "SuggestBreakdown", "title": "Get Fit", "frequencyUnit": "Day", "frequencyQuantity": 1, "dueDate": "{TODAY}", "suggestedSubHabits": [{ "type": "CreateHabit", "title": "Morning Run", "description": "30min jog", "frequencyUnit": "Day", "frequencyQuantity": 1, "dueDate": "{TODAY}" }, { "type": "CreateHabit", "title": "Stretching", "frequencyUnit": "Day", "frequencyQuantity": 1, "dueDate": "{TODAY}" }] }], "aiMessage": "Here's a plan to get fit! Review and let me know what you think." }""")]
public class SuggestBreakdownAction
{
[AiField("string", "Parent habit name", Required = true)]
public string Title { get; init; } = default!;

[AiField("string", "Optional description")]
public string? Description { get; init; }

[AiField("Day|Week|Month|Year", "Frequency unit")]
public string? FrequencyUnit { get; init; }

[AiField("integer", "Frequency quantity")]
public int? FrequencyQuantity { get; init; }

[AiField("string", "YYYY-MM-DD due date")]
public string? DueDate { get; init; }

[AiField("object[]", "Array of habit objects with type: \"CreateHabit\", title, description, frequencyUnit, frequencyQuantity, dueDate")]
public object? SuggestedSubHabits { get; init; }
}
10 changes: 10 additions & 0 deletions src/Orbit.Application/Common/Attributes/AiActionAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Orbit.Application.Common.Attributes;

[AttributeUsage(AttributeTargets.Class)]
public class AiActionAttribute(string actionType, string capability, string whenToUse) : Attribute
{
public string ActionType { get; } = actionType;
public string Capability { get; } = capability;
public string WhenToUse { get; } = whenToUse;
public int DisplayOrder { get; set; } = 100;
}
9 changes: 9 additions & 0 deletions src/Orbit.Application/Common/Attributes/AiExampleAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Orbit.Application.Common.Attributes;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class AiExampleAttribute(string userMessage, string jsonResponse) : Attribute
{
public string UserMessage { get; } = userMessage;
public string JsonResponse { get; } = jsonResponse;
public string? Note { get; set; }
}
10 changes: 10 additions & 0 deletions src/Orbit.Application/Common/Attributes/AiFieldAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Orbit.Application.Common.Attributes;

[AttributeUsage(AttributeTargets.Property)]
public class AiFieldAttribute(string type, string description) : Attribute
{
public string Type { get; } = type;
public string Description { get; } = description;
public bool Required { get; set; }
public string? Name { get; set; }
}
7 changes: 7 additions & 0 deletions src/Orbit.Application/Common/Attributes/AiRuleAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Orbit.Application.Common.Attributes;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class AiRuleAttribute(string rule) : Attribute
{
public string Rule { get; } = rule;
}
74 changes: 60 additions & 14 deletions src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,70 @@
using Orbit.Domain.Interfaces;
using Orbit.Domain.ValueObjects;

using Orbit.Application.Common.Attributes;

namespace Orbit.Application.Habits.Commands;

[AiAction(
"CreateHabit",
"""**Create and track habits** (e.g., "I want to meditate daily", "I want to run 5km every week")""",
"""
- User explicitly tells you what to create with clear details: "create a daily running habit", "add morning routine with meditate, journal, stretch"
- It's a simple one-time task: "buy eggs today"
- Use CreateHabit with subHabits when user explicitly lists what the sub-habits should be
""",
DisplayOrder = 10)]
[AiRule("ALWAYS include dueDate when creating habits")]
[AiRule("Omit frequencyUnit and frequencyQuantity for one-time tasks")]
[AiRule("days CANNOT be set if frequencyQuantity > 1")]
[AiRule("Only include tagNames if user explicitly mentioned tagging it")]
[AiRule("BAD HABITS: Set isBadHabit to true for habits the user wants to AVOID or STOP doing. Bad habits track slip-ups/occurrences (smoking, nail biting, etc.)")]
[AiRule("When logging habits, include a note if the user provides context or feelings about the activity")]
[AiRule("TAGS: You can assign tags to habits using tagNames on CreateHabit actions, or use AssignTags action to change tags on existing habits. tagNames is an array of tag name strings. Use EXISTING tag names from the user's tags list when possible. If user asks for a tag that doesn't exist yet, use the new name - it will be auto-created")]
[AiRule("ONLY add/change tags when the user EXPLICITLY asks for it. NEVER auto-assign tags on your own initiative")]
[AiExample(
"I want to meditate on weekdays",
"""{ "actions": [{ "type": "CreateHabit", "title": "Meditation", "frequencyUnit": "Day", "frequencyQuantity": 1, "days": ["Monday","Tuesday","Wednesday","Thursday","Friday"], "dueDate": "{TODAY}" }], "aiMessage": "Created a weekday meditation habit!" }""")]
[AiExample(
"Create workout plan with gym MWF and cardio TuTh",
"""{ "actions": [{ "type": "CreateHabit", "title": "Workout Plan", "frequencyUnit": "Day", "frequencyQuantity": 1, "subHabits": [{ "title": "Gym", "days": ["Monday","Wednesday","Friday"] }, { "title": "Cardio", "days": ["Tuesday","Thursday"] }], "dueDate": "{TODAY}" }], "aiMessage": "Created your Workout Plan!" }""",
Note = "with subHabits")]
[AiExample(
"I want to stop smoking",
"""{ "actions": [{ "type": "CreateHabit", "title": "Smoking", "frequencyUnit": "Day", "frequencyQuantity": 1, "isBadHabit": true, "slipAlertEnabled": true, "dueDate": "{TODAY}" }], "aiMessage": "Tracking smoking as a bad habit with slip alerts enabled. Log each slip-up and I'll send you motivational nudges before your usual slip times!" }""",
Note = "bad habit")]
[AiExample(
"Buy eggs tomorrow",
"""{ "actions": [{ "type": "CreateHabit", "title": "Buy Eggs", "dueDate": "{TOMORROW}" }], "aiMessage": "Got it, buy eggs tomorrow!" }""",
Note = "one-time task")]
[AiExample(
"Dentist appointment tomorrow at 3pm",
"""{ "actions": [{ "type": "CreateHabit", "title": "Dentist Appointment", "dueDate": "{TOMORROW}", "dueTime": "15:00" }], "aiMessage": "Scheduled your dentist appointment for tomorrow at 3pm!" }""",
Note = "with time")]
[AiExample(
"Exam tomorrow at 5pm, remind me 30 minutes before",
"""{ "actions": [{ "type": "CreateHabit", "title": "Exam", "dueDate": "{TOMORROW}", "dueTime": "17:00", "reminderEnabled": true, "reminderMinutesBefore": 30 }], "aiMessage": "Scheduled your exam for tomorrow at 5pm with a reminder 30 minutes before!" }""",
Note = "with reminder")]
[AiExample(
"Create a supermarket list with milk, eggs, bread",
"""{ "actions": [{ "type": "CreateHabit", "title": "Supermarket", "dueDate": "{TODAY}", "checklistItems": [{"text": "Milk", "isChecked": false}, {"text": "Eggs", "isChecked": false}, {"text": "Bread", "isChecked": false}] }], "aiMessage": "Created your supermarket list with 3 items!" }""",
Note = "with checklist")]
public record CreateHabitCommand(
Guid UserId,
string Title,
string? Description,
FrequencyUnit? FrequencyUnit,
int? FrequencyQuantity,
IReadOnlyList<System.DayOfWeek>? Days = null,
bool IsBadHabit = false,
IReadOnlyList<string>? SubHabits = null,
DateOnly? DueDate = null,
TimeOnly? DueTime = null,
bool ReminderEnabled = false,
int ReminderMinutesBefore = 15,
bool SlipAlertEnabled = false,
IReadOnlyList<Guid>? TagIds = null,
IReadOnlyList<ChecklistItem>? ChecklistItems = null) : IRequest<Result<Guid>>;
[property: AiField("string", "Name of the habit", Required = true)] string Title,
[property: AiField("string", "Optional description")] string? Description,
[property: AiField("Day|Week|Month|Year", "OMIT for one-time tasks")] FrequencyUnit? FrequencyUnit,
[property: AiField("integer", "Defaults to 1. OMIT for one-time tasks")] int? FrequencyQuantity,
[property: AiField("string[]", "Specific weekdays, only when frequencyQuantity is 1")] IReadOnlyList<System.DayOfWeek>? Days = null,
[property: AiField("boolean", "True for habits the user wants to AVOID or STOP doing")] bool IsBadHabit = false,
[property: AiField("object[]", "Array of sub-habit OBJECTS, each with: title (REQUIRED), plus optional frequencyUnit, frequencyQuantity, days, dueDate, description, isBadHabit. Sub-habits INHERIT parent frequency/dueDate when those fields are omitted.")] IReadOnlyList<string>? SubHabits = null,
[property: AiField("string", "YYYY-MM-DD, when the habit starts or is due", Required = true)] DateOnly? DueDate = null,
[property: AiField("string", "HH:mm 24h format, e.g. \"15:00\" for 3pm. ONLY include when user mentions a specific time")] TimeOnly? DueTime = null,
[property: AiField("boolean", "Set true when user asks for a reminder/notification")] bool ReminderEnabled = false,
[property: AiField("integer", "Minutes before dueTime to send reminder, default 15")] int ReminderMinutesBefore = 15,
[property: AiField("boolean", "Defaults to true when isBadHabit is true -- sends AI-generated motivational alerts before predicted slip windows")] bool SlipAlertEnabled = false,
[property: AiField("string[]", "Array of tag name strings, ONLY when user explicitly asks to tag it", Name = "tagNames")] IReadOnlyList<Guid>? TagIds = null,
[property: AiField("object[]", "Array of {text, isChecked} for inline checklists, e.g. shopping lists, packing lists. Use INSTEAD of sub-habits when user wants a simple checklist within a habit")] IReadOnlyList<ChecklistItem>? ChecklistItems = null) : IRequest<Result<Guid>>;

public class CreateHabitCommandHandler(
IGenericRepository<Habit> habitRepository,
Expand Down
23 changes: 20 additions & 3 deletions src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,30 @@
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

using Orbit.Application.Common.Attributes;

namespace Orbit.Application.Habits.Commands;

[AiAction(
"CreateSubHabit",
"""**Add sub-habits** to existing parent habits (e.g., "add stretching under my Workout Plan")""",
"""
- User wants to add a sub-habit to an existing parent: "add X under Y", "create X as part of my Y habit"
- The parent habit MUST already exist in the Active Habits list with a known ID
- Do NOT use CreateSubHabit when the parent doesn't exist yet -- use CreateHabit with subHabits instead
- Do NOT use CreateSubHabit for standalone habits -- use CreateHabit
""",
DisplayOrder = 50)]
[AiRule("Use CreateSubHabit to add a sub-habit under an EXISTING parent from the Active Habits list. Requires habitId (the parent's exact ID) and title. The sub-habit inherits the parent's frequency and scheduling. Do NOT confuse with CreateHabit+subHabits which creates a NEW parent. If the parent habit doesn't exist yet, use CreateHabit instead.")]
[AiExample(
"Add stretching under my Workout Plan",
"""{ "actions": [{ "type": "CreateSubHabit", "habitId": "abc-123", "title": "Stretching", "description": "Post-workout stretching routine" }], "aiMessage": "Added Stretching as a sub-habit under Workout Plan!" }""",
Note = """Workout Plan ID: "abc-123" """)]
public record CreateSubHabitCommand(
Guid UserId,
Guid ParentHabitId,
string Title,
string? Description) : IRequest<Result<Guid>>;
[property: AiField("string", "ID of existing PARENT habit from Active Habits list", Required = true, Name = "habitId")] Guid ParentHabitId,
[property: AiField("string", "Name of the new sub-habit", Required = true)] string Title,
[property: AiField("string", "Optional description")] string? Description) : IRequest<Result<Guid>>;

public class CreateSubHabitCommandHandler(
IGenericRepository<Habit> habitRepository,
Expand Down
21 changes: 20 additions & 1 deletion src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,28 @@
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

using Orbit.Application.Common.Attributes;

namespace Orbit.Application.Habits.Commands;

public record DeleteHabitCommand(Guid UserId, Guid HabitId) : IRequest<Result>;
[AiAction(
"DeleteHabit",
"""**Delete habits** when asked (e.g., "delete my running habit", "remove all bad habits")""",
"""
- User explicitly asks to delete, remove, or get rid of a habit: "delete my running habit", "remove meditation"
- User says "I don't want to track X anymore"
- For a SINGLE habit deletion, execute immediately
- For bulk deletes (2+ habits, e.g. "remove all my bad habits"), do NOT execute immediately. List the habits that would be deleted in aiMessage and ask for confirmation first. Only delete after they confirm.
- ALWAYS confirm in aiMessage what was deleted
""",
DisplayOrder = 40)]
[AiExample(
"Delete my running habit",
"""{ "actions": [{ "type": "DeleteHabit", "habitId": "abc-123" }], "aiMessage": "Deleted Running!" }""",
Note = """Running ID: "abc-123" """)]
public record DeleteHabitCommand(
Guid UserId,
[property: AiField("string", "ID of existing habit to delete", Required = true)] Guid HabitId) : IRequest<Result>;

public class DeleteHabitCommandHandler(
IGenericRepository<Habit> habitRepository,
Expand Down
19 changes: 17 additions & 2 deletions src/Orbit.Application/Habits/Commands/LogHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,27 @@
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

using Orbit.Application.Common.Attributes;

namespace Orbit.Application.Habits.Commands;

[AiAction(
"LogHabit",
"""**Log habit completions** with optional notes (e.g., "I ran today, felt great!")""",
"""
- User mentions completing an activity that matches an EXISTING habit from the Active Habits list
- Use the exact habit ID from the list
- Include a note if the user shares context or feelings about the activity
""",
DisplayOrder = 20)]
[AiExample(
"I ran today, felt great",
"""{ "actions": [{ "type": "LogHabit", "habitId": "abc-123", "note": "felt great" }], "aiMessage": "Logged your run!" }""",
Note = """Running ID: "abc-123" """)]
public record LogHabitCommand(
Guid UserId,
Guid HabitId,
string? Note = null) : IRequest<Result<Guid>>;
[property: AiField("string", "ID of the habit to log", Required = true)] Guid HabitId,
[property: AiField("string", "Include if user shares context or feelings")] string? Note = null) : IRequest<Result<Guid>>;

public class LogHabitCommandHandler(
IGenericRepository<Habit> habitRepository,
Expand Down
46 changes: 33 additions & 13 deletions src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,43 @@
using Orbit.Domain.ValueObjects;
using System.Linq.Expressions;

using Orbit.Application.Common.Attributes;

namespace Orbit.Application.Habits.Commands;

[AiAction(
"UpdateHabit",
"""**Update habits** -- change title, frequency, due date, or any property (e.g., "move my gym to tomorrow", "rename running to jogging")""",
"""
- User asks to change a habit's date, frequency, name, or any property: "move my gym to tomorrow", "change running to weekly"
- User asks to reschedule: "push all my habits to tomorrow", "change the date of meditation to next Monday"
- User asks to rename: "rename my running habit to jogging"
- For BULK updates, return MULTIPLE UpdateHabit actions, one per habit
- ONLY update fields the user mentions. Omit unchanged fields.
- IMPORTANT: When user says "move ALL my habits to tomorrow" or "reschedule everything", they mean habits due TODAY and OVERDUE ones only. Do NOT move habits scheduled for future dates. Check each habit's Due date -- only include those where Due <= today's date.
- **CONFIRM BEFORE BULK CHANGES:** When a request affects 3+ habits (bulk reschedule, bulk delete, bulk update), do NOT execute immediately. Instead, return EMPTY actions and list the affected habits in aiMessage, asking the user to confirm. Only execute after they confirm.
""",
DisplayOrder = 30)]
[AiRule("Only include fields that are changing in UpdateHabit actions")]
[AiExample(
"Move my gym to tomorrow",
"""{ "actions": [{ "type": "UpdateHabit", "habitId": "abc-123", "dueDate": "{TOMORROW}" }], "aiMessage": "Moved Gym to tomorrow!" }""",
Note = """Gym ID: "abc-123" """)]
public record UpdateHabitCommand(
Guid UserId,
Guid HabitId,
string Title,
string? Description,
FrequencyUnit? FrequencyUnit,
int? FrequencyQuantity,
IReadOnlyList<System.DayOfWeek>? Days = null,
bool IsBadHabit = false,
DateOnly? DueDate = null,
TimeOnly? DueTime = null,
bool? ReminderEnabled = null,
int? ReminderMinutesBefore = null,
bool? SlipAlertEnabled = null,
IReadOnlyList<ChecklistItem>? ChecklistItems = null) : IRequest<Result>;
[property: AiField("string", "ID of existing habit", Required = true)] Guid HabitId,
[property: AiField("string", "New title")] string Title,
[property: AiField("string", "New description")] string? Description,
[property: AiField("Day|Week|Month|Year", "New frequency unit")] FrequencyUnit? FrequencyUnit,
[property: AiField("integer", "New frequency quantity")] int? FrequencyQuantity,
[property: AiField("string[]", "New specific weekdays")] IReadOnlyList<System.DayOfWeek>? Days = null,
[property: AiField("boolean", "New bad habit status")] bool IsBadHabit = false,
[property: AiField("string", "New due date YYYY-MM-DD")] DateOnly? DueDate = null,
[property: AiField("string", "HH:mm 24h format to set or change time")] TimeOnly? DueTime = null,
[property: AiField("boolean", "Enable or disable reminders")] bool? ReminderEnabled = null,
[property: AiField("integer", "Minutes before dueTime to send reminder")] int? ReminderMinutesBefore = null,
[property: AiField("boolean", "Enable or disable slip alerts")] bool? SlipAlertEnabled = null,
[property: AiField("object[]", "New checklist items")] IReadOnlyList<ChecklistItem>? ChecklistItems = null) : IRequest<Result>;

public class UpdateHabitCommandHandler(
IGenericRepository<Habit> habitRepository,
Expand Down
Loading