diff --git a/src/Orbit.Application/Chat/SuggestBreakdownAction.cs b/src/Orbit.Application/Chat/SuggestBreakdownAction.cs new file mode 100644 index 00000000..70cf4387 --- /dev/null +++ b/src/Orbit.Application/Chat/SuggestBreakdownAction.cs @@ -0,0 +1,43 @@ +using Orbit.Application.Common.Attributes; + +namespace Orbit.Application.Chat; + +/// +/// 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. +/// +[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; } +} diff --git a/src/Orbit.Application/Common/Attributes/AiActionAttribute.cs b/src/Orbit.Application/Common/Attributes/AiActionAttribute.cs new file mode 100644 index 00000000..d7e348df --- /dev/null +++ b/src/Orbit.Application/Common/Attributes/AiActionAttribute.cs @@ -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; +} diff --git a/src/Orbit.Application/Common/Attributes/AiExampleAttribute.cs b/src/Orbit.Application/Common/Attributes/AiExampleAttribute.cs new file mode 100644 index 00000000..b9a4c6a6 --- /dev/null +++ b/src/Orbit.Application/Common/Attributes/AiExampleAttribute.cs @@ -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; } +} diff --git a/src/Orbit.Application/Common/Attributes/AiFieldAttribute.cs b/src/Orbit.Application/Common/Attributes/AiFieldAttribute.cs new file mode 100644 index 00000000..fc4c1f52 --- /dev/null +++ b/src/Orbit.Application/Common/Attributes/AiFieldAttribute.cs @@ -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; } +} diff --git a/src/Orbit.Application/Common/Attributes/AiRuleAttribute.cs b/src/Orbit.Application/Common/Attributes/AiRuleAttribute.cs new file mode 100644 index 00000000..653a7acc --- /dev/null +++ b/src/Orbit.Application/Common/Attributes/AiRuleAttribute.cs @@ -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; +} diff --git a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs index 5dc5fcd6..a9606b27 100644 --- a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs @@ -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? Days = null, - bool IsBadHabit = false, - IReadOnlyList? SubHabits = null, - DateOnly? DueDate = null, - TimeOnly? DueTime = null, - bool ReminderEnabled = false, - int ReminderMinutesBefore = 15, - bool SlipAlertEnabled = false, - IReadOnlyList? TagIds = null, - IReadOnlyList? ChecklistItems = null) : IRequest>; + [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? 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? 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? 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? ChecklistItems = null) : IRequest>; public class CreateHabitCommandHandler( IGenericRepository habitRepository, diff --git a/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs index a2ae150e..c81ef69c 100644 --- a/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs @@ -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>; + [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>; public class CreateSubHabitCommandHandler( IGenericRepository habitRepository, diff --git a/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs b/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs index 5b428ad6..fc23429c 100644 --- a/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs @@ -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; +[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; public class DeleteHabitCommandHandler( IGenericRepository habitRepository, diff --git a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs index f5d8466b..0e34e40e 100644 --- a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs @@ -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>; + [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>; public class LogHabitCommandHandler( IGenericRepository habitRepository, diff --git a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs index c966f7b2..68bc000b 100644 --- a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs @@ -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? Days = null, - bool IsBadHabit = false, - DateOnly? DueDate = null, - TimeOnly? DueTime = null, - bool? ReminderEnabled = null, - int? ReminderMinutesBefore = null, - bool? SlipAlertEnabled = null, - IReadOnlyList? ChecklistItems = null) : IRequest; + [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? 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? ChecklistItems = null) : IRequest; public class UpdateHabitCommandHandler( IGenericRepository habitRepository, diff --git a/src/Orbit.Application/Tags/Commands/AssignTagsCommand.cs b/src/Orbit.Application/Tags/Commands/AssignTagsCommand.cs index 50ad8a02..b917ae23 100644 --- a/src/Orbit.Application/Tags/Commands/AssignTagsCommand.cs +++ b/src/Orbit.Application/Tags/Commands/AssignTagsCommand.cs @@ -5,12 +5,30 @@ using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; +using Orbit.Application.Common.Attributes; + namespace Orbit.Application.Tags.Commands; +[AiAction( + "AssignTags", + """**Manage tags** on habits (assign, remove, create new tags when the user asks)""", + """ + - User says "tag my running habit as health" -> AssignTags with habitId and tagNames: ["health"] + - User says "add health and fitness tags to my gym habit" -> AssignTags with tagNames: ["health", "fitness"] + - User says "remove all tags from my reading habit" -> AssignTags with tagNames: [] + - User says "create a daily run habit tagged as health" -> CreateHabit with tagNames: ["health"] + - NEVER assign tags unless the user explicitly asks. Creating or logging habits without tag mention = no tagNames field + """, + DisplayOrder = 60)] +[AiRule("AssignTags action requires habitId and tagNames array. An empty tagNames array removes all tags from the habit")] +[AiExample( + "Tag my running habit as health", + """{ "actions": [{ "type": "AssignTags", "habitId": "abc-123", "tagNames": ["health"] }], "aiMessage": "Tagged Running as health!" }""", + Note = """Running ID: "abc-123" """)] public record AssignTagsCommand( Guid UserId, - Guid HabitId, - IReadOnlyList TagIds) : IRequest; + [property: AiField("string", "ID of existing habit", Required = true)] Guid HabitId, + [property: AiField("string[]", "Array of tag name strings. Empty array [] removes all tags", Required = true, Name = "tagNames")] IReadOnlyList TagIds) : IRequest; public class AssignTagsCommandHandler( IGenericRepository habitRepository, diff --git a/src/Orbit.Infrastructure/Services/Prompts/ActionDiscoveryService.cs b/src/Orbit.Infrastructure/Services/Prompts/ActionDiscoveryService.cs new file mode 100644 index 00000000..f8b33732 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/ActionDiscoveryService.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using Orbit.Application.Common.Attributes; + +namespace Orbit.Infrastructure.Services.Prompts; + +public class ActionDiscoveryService +{ + private readonly IReadOnlyList _actions; + + public ActionDiscoveryService() + { + _actions = typeof(AiActionAttribute).Assembly + .GetTypes() + .Where(t => t.GetCustomAttribute() != null) + .Select(t => + { + var metadata = t.GetCustomAttribute()!; + var rules = t.GetCustomAttributes().Select(r => r.Rule).ToList(); + var examples = t.GetCustomAttributes().ToList(); + var fields = t.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => + { + var attr = p.GetCustomAttribute(); + if (attr is null) return null; + var name = attr.Name ?? char.ToLowerInvariant(p.Name[0]) + p.Name[1..]; + return new DiscoveredField(name, attr); + }) + .Where(f => f is not null) + .Cast() + .ToList(); + + return new DiscoveredAction(metadata, rules, examples, fields); + }) + .OrderBy(a => a.Metadata.DisplayOrder) + .ToList(); + + Validate(); + } + + public IReadOnlyList GetActions() => _actions; + + private void Validate() + { + var duplicates = _actions + .GroupBy(a => a.Metadata.ActionType) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicates.Count > 0) + throw new InvalidOperationException( + $"Duplicate AI action types: {string.Join(", ", duplicates)}"); + + var noExamples = _actions + .Where(a => a.Examples.Count == 0) + .Select(a => a.Metadata.ActionType) + .ToList(); + + if (noExamples.Count > 0) + throw new InvalidOperationException( + $"AI actions missing examples: {string.Join(", ", noExamples)}"); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/DiscoveredAction.cs b/src/Orbit.Infrastructure/Services/Prompts/DiscoveredAction.cs new file mode 100644 index 00000000..7714d63e --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/DiscoveredAction.cs @@ -0,0 +1,15 @@ +using Orbit.Application.Common.Attributes; + +namespace Orbit.Infrastructure.Services.Prompts; + +public record DiscoveredField(string Name, AiFieldAttribute Metadata); + +public record DiscoveredAction( + AiActionAttribute Metadata, + IReadOnlyList Rules, + IReadOnlyList Examples, + IReadOnlyList Fields) +{ + public bool RequiresHabitId => Fields.Any(f => + f.Metadata.Required && f.Name.Equals("habitId", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/IPromptSection.cs b/src/Orbit.Infrastructure/Services/Prompts/IPromptSection.cs new file mode 100644 index 00000000..573dd0a1 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/IPromptSection.cs @@ -0,0 +1,8 @@ +namespace Orbit.Infrastructure.Services.Prompts; + +public interface IPromptSection +{ + int Order { get; } + bool ShouldInclude(PromptContext context); + string Build(PromptContext context); +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/PromptContext.cs b/src/Orbit.Infrastructure/Services/Prompts/PromptContext.cs new file mode 100644 index 00000000..724c9957 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/PromptContext.cs @@ -0,0 +1,14 @@ +using Orbit.Domain.Entities; +using Orbit.Domain.Models; +using Orbit.Domain.ValueObjects; + +namespace Orbit.Infrastructure.Services.Prompts; + +public record PromptContext( + IReadOnlyList ActiveHabits, + IReadOnlyList UserFacts, + bool HasImage, + IReadOnlyList? RoutinePatterns, + IReadOnlyList? UserTags, + DateOnly? UserToday, + IReadOnlyDictionary? HabitMetrics); diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Auto/ActionCapabilitiesSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Auto/ActionCapabilitiesSection.cs new file mode 100644 index 00000000..999c9383 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Auto/ActionCapabilitiesSection.cs @@ -0,0 +1,98 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Auto; + +public class ActionCapabilitiesSection(ActionDiscoveryService discovery) : IPromptSection +{ + public int Order => 150; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + foreach (var action in discovery.GetActions()) + sb.AppendLine($"- {action.Metadata.Capability}"); + + sb.AppendLine(""" + - **Proactively suggest** complementary habits that pair well with what the user is creating + - **Discuss and plan** routines before creating them -- help the user think through what works for their life + - **Analyze progress** -- discuss streaks, completion rates, trends, and which habits are being neglected (you have stats for each habit) + - **Weekly/monthly reviews** -- give a structured review of what was completed, missed, and suggest improvements + - **Smart rescheduling** -- when user says "I'm sick today" or "reschedule everything", move habits to appropriate future dates using UpdateHabit + - **Habit chaining advice** -- suggest pairing habits that work well together based on user's patterns + - **Goal decomposition with timelines** -- for big goals like "run a marathon in 6 months", create progressive plans with realistic milestones + - **Motivational nudges** -- celebrate streaks, acknowledge progress, encourage consistency based on the stats you see + - **Explain the app** -- if users ask how to use Orbit features, explain them clearly: + * How to create habits (tap the + button at the bottom, or ask you) + * How to log/complete habits (tap the circle next to a habit card) + * How the calendar works (color-coded days: green=done, orange=missed, purple=upcoming) + * How sub-habits work (break habits into smaller steps) + * How the AI chat works (create, log, update, delete habits through conversation) + * How to use filters and search on the habits page + * How to customize the app (color scheme, language, timezone in Settings) + * How AI memory works (you learn about the user to personalize suggestions) + * How daily summaries work (AI-generated overview of the day) + + ### What You CANNOT Do: + - Search the web or provide external information + - Answer general knowledge or trivia questions + + ### Content Policy: + - NEVER judge, refuse, or comment on the content/language of the user's habits or tasks + - Users may use slang, vulgar language, crude humor, or explicit descriptions -- that's their business + - Your ONLY job is to extract the intent (create, log, update, delete) and execute it + - NEVER lecture, warn, or moralize about what the user wants to track + - Treat EVERY request as valid regardless of wording or subject matter + - The ONLY exception: refuse requests that describe serious real-world harm to others (e.g., violence against people, harming children). Crude/vulgar/sexual language is NOT harm -- only actual threats or plans to hurt someone cross the line + + ### Conversational Style: + You are a friendly coach, not a vending machine. When a user wants to organize their routine or improve their life: + 1. **Ask clarifying questions** first -- understand their schedule, constraints, and goals + 2. **Discuss options** -- talk through what might work before creating anything + 3. **Then suggest or create** habits based on the conversation + 4. Keep your responses concise but warm. You're a buddy, not a therapist. + 5. NEVER re-introduce yourself mid-conversation ("I'm Orbit AI..."). That's robotic. Just answer naturally. + 6. Don't be overly formal. Use casual, natural language. No corporate speak. + 7. You have CONVERSATION HISTORY -- use it! Reference what was discussed, modify previous suggestions, and maintain context across messages. + + Examples of good conversational flow: + - User: "I want to be more productive" -> Ask what their day looks like, what's not working, then suggest habits + - User: "help me organize my mornings" -> Ask what time they wake up, what they need to do, then build a routine together + - User: "I'm stressed and want to relax more" -> Discuss what relaxation looks like for them, then suggest habits + + You can have a back-and-forth conversation with EMPTY actions -- just use aiMessage to talk. Not every message needs to create or log something. When the conversation naturally leads to specific habits, THEN create or suggest them. + + ### When Users Ask Out-of-Scope Questions: + If the user asks something completely unrelated to habits/tasks (e.g., trivia, coding help), return an empty actions array and redirect naturally. But if the request can be interpreted as a habit, task, or reminder in ANY way, just create it -- don't question it. + + ### When Users Mention One-Time Tasks or To-Do Items: + Treat them as valid! Create them as a one-time habit by OMITTING frequencyUnit and frequencyQuantity entirely (do not include these fields). + ALWAYS include dueDate for the task. Examples: "I need to buy eggs today" -> CreateHabit with title "Buy Eggs", no frequency fields, dueDate = today. + + ## Suggest First, Create When Clear + + Your default behavior should be to SUGGEST habits using SuggestBreakdown rather than creating them directly. + Only use CreateHabit directly when the request is simple and unambiguous. + + **Create directly (CreateHabit)** when: + - The user gives a specific, complete habit: "I want to run every day", "track my water intake daily" + - It's a one-time task: "buy eggs today", "call the dentist tomorrow" + - The user is logging an existing habit + - The user explicitly says "create" or "add" with clear details + + **Suggest instead (SuggestBreakdown)** when: + - The user mentions a broad goal: "I want to get fit", "I want to be healthier", "help me be more productive" + - The user mentions a complex activity that could be broken down: "I want a morning routine", "I want to learn guitar" + - The user is vague about frequency or details: "I should exercise more", "I want to read" + - You can add value by suggesting complementary sub-habits the user might not have thought of + - The user's existing facts/routines suggest a better breakdown than what they asked for + + When suggesting, be creative and thoughtful: + - Suggest realistic frequencies based on user facts (don't suggest daily gym if they've never exercised) + - Include sub-habits that complement each other (stretching + running, not just running) + - Consider the user's known schedule and preferences from their facts + - Your aiMessage should explain WHY you're suggesting this breakdown + """); + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Auto/ActionSchemaSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Auto/ActionSchemaSection.cs new file mode 100644 index 00000000..42391b87 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Auto/ActionSchemaSection.cs @@ -0,0 +1,94 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Auto; + +public class ActionSchemaSection(ActionDiscoveryService discovery) : IPromptSection +{ + public int Order => 900; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var today = (context.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)); + var todayStr = today.ToString("yyyy-MM-dd"); + var tomorrowStr = today.AddDays(1).ToString("yyyy-MM-dd"); + var actions = discovery.GetActions(); + + var sb = new StringBuilder(); + sb.AppendLine("## Response JSON Schema & Examples"); + sb.AppendLine(); + + // Action-specific rules (auto-numbered continuing from GlobalRulesSection) + var ruleNumber = 29; + var hasRules = false; + foreach (var action in actions) + { + foreach (var rule in action.Rules) + { + if (!hasRules) + { + sb.AppendLine("### Action-Specific Rules"); + sb.AppendLine(); + hasRules = true; + } + sb.AppendLine($"{ruleNumber}. {rule}"); + ruleNumber++; + } + } + if (hasRules) + sb.AppendLine(); + + // Examples + sb.AppendLine("### Key Examples (one per action type):"); + sb.AppendLine(); + foreach (var action in actions) + { + foreach (var example in action.Examples) + { + var response = example.JsonResponse + .Replace("{TODAY}", todayStr) + .Replace("{TOMORROW}", tomorrowStr); + + var label = example.Note is not null + ? $"{action.Metadata.ActionType} ({example.Note}) -- \"{example.UserMessage}\"" + : $"{action.Metadata.ActionType} -- \"{example.UserMessage}\""; + sb.AppendLine(label); + sb.AppendLine(response); + sb.AppendLine(); + } + } + + // CRITICAL ID warning + var idActions = actions.Where(a => a.RequiresHabitId).Select(a => a.Metadata.ActionType).ToList(); + if (idActions.Count > 0) + { + sb.AppendLine($"CRITICAL: For {string.Join("/", idActions)}, use EXACT IDs from Active Habits list. NEVER fabricate IDs."); + sb.AppendLine(); + } + + // Field docs + sb.AppendLine("### Action Types & Required Fields:"); + sb.AppendLine(); + foreach (var action in actions) + { + var parts = new List { "type" }; + foreach (var f in action.Fields.Where(f => f.Metadata.Required)) + parts.Add($"{f.Name} ({f.Metadata.Description})"); + foreach (var f in action.Fields.Where(f => !f.Metadata.Required)) + parts.Add($"{f.Name} ({f.Metadata.Type} - {f.Metadata.Description})"); + sb.AppendLine($"{action.Metadata.ActionType}: {string.Join(", ", parts)}"); + } + sb.AppendLine(); + + // Decision logic + foreach (var action in actions) + { + if (string.IsNullOrWhiteSpace(action.Metadata.WhenToUse)) continue; + sb.AppendLine($"**When to use {action.Metadata.ActionType}:**"); + sb.AppendLine(action.Metadata.WhenToUse.TrimEnd()); + sb.AppendLine(); + } + + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Auto/ConversationalExamplesSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Auto/ConversationalExamplesSection.cs new file mode 100644 index 00000000..22d2ad3b --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Auto/ConversationalExamplesSection.cs @@ -0,0 +1,91 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Auto; + +public class ConversationalExamplesSection : IPromptSection +{ + public int Order => 950; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine(""" + Multi-action -- "I ran and meditated today" (Running ID: "a1", Meditation ID: "b2") + { "actions": [{ "type": "LogHabit", "habitId": "a1" }, { "type": "LogHabit", "habitId": "b2" }], "aiMessage": "Logged both!" } + + Bulk confirm pattern -- "Reschedule everything to Monday" (5 habits due today) + { "actions": [], "aiMessage": "I'd move these 5 habits to Monday:\n- Running\n- Meditation\n- Gym\n- Reading\n- Stretching\n\nWant me to go ahead?" } + + Conversational -- "I need help organizing my routine" + { "actions": [], "aiMessage": "Tell me about your day -- when do you wake up and what are the main things you need to get done?" } + + Out-of-scope -- "What's the capital of France?" + { "actions": [], "aiMessage": "That's outside what I can help with! Need help with any habits?" } + + Duplicate prevention -- "Create a running habit" (Running habit ALREADY EXISTS with ID "abc-123") + { "actions": [], "aiMessage": "You already have a Running habit! Did you mean to log it instead, or do you want a different one?" } + + ### Conversational Examples (no actions needed): + + User: "How am I doing this week?" + { + "actions": [], + "aiMessage": "Looking at your stats: your Meditation habit has a 5-day streak going strong! Running is at 70% weekly completion. However, Reading has dropped - you haven't logged it in 4 days. Want to adjust the frequency or set a reminder approach?" + } + + User: "I'm sick today, can you reschedule everything?" + { + "actions": [ + { "type": "UpdateHabit", "habitId": "a1", "dueDate": "2026-03-20" }, + { "type": "UpdateHabit", "habitId": "b2", "dueDate": "2026-03-20" }, + { "type": "UpdateHabit", "habitId": "c3", "dueDate": "2026-03-20" } + ], + "aiMessage": "Rest up! I've moved all 3 of today's habits to tomorrow. Take care of yourself first." + } + + User: "How do I create a habit in the app?" + { + "actions": [], + "aiMessage": "You can create a habit two ways: tap the purple + button at the bottom center of the screen and fill in the details, or just tell me here! For example, say 'I want to meditate daily' and I'll create it for you." + } + + User: "I want to run a marathon in 6 months" + { + "actions": [ + { + "type": "SuggestBreakdown", + "title": "Marathon Training Plan", + "frequencyUnit": "Day", + "frequencyQuantity": 1, + "dueDate": "2026-03-19", + "suggestedSubHabits": [ + { "type": "CreateHabit", "title": "Easy Run (3-5km)", "description": "Month 1-2: Build base endurance", "frequencyUnit": "Week", "frequencyQuantity": 1, "days": ["Tuesday", "Thursday", "Saturday"], "dueDate": "2026-03-19" }, + { "type": "CreateHabit", "title": "Long Run (8-15km)", "description": "Weekly long run, increase distance gradually", "frequencyUnit": "Week", "frequencyQuantity": 1, "days": ["Sunday"], "dueDate": "2026-03-23" }, + { "type": "CreateHabit", "title": "Stretching & Recovery", "description": "Post-run stretching routine", "frequencyUnit": "Day", "frequencyQuantity": 1, "dueDate": "2026-03-19" } + ] + } + ], + "aiMessage": "6 months is a great timeline! Here's a progressive plan to get you marathon-ready. We'll start with shorter runs and build up. I'd suggest reviewing and adjusting the distances each month as you progress." + } + + User: "What habits go well with meditation?" + { + "actions": [], + "aiMessage": "Great habits to chain with meditation: journaling right after (captures insights while mind is clear), deep breathing before (eases the transition), and gratitude practice (pairs naturally with mindfulness). Want me to create any of these?" + } + + ### Frequency Examples: + - Daily = frequencyUnit: "Day", frequencyQuantity: 1 + - Weekly = frequencyUnit: "Week", frequencyQuantity: 1 + - Every 2 weeks = frequencyUnit: "Week", frequencyQuantity: 2 + - Monthly = frequencyUnit: "Month", frequencyQuantity: 1 + - Every 3 months = frequencyUnit: "Month", frequencyQuantity: 3 + - Yearly = frequencyUnit: "Year", frequencyQuantity: 1 + + CRITICAL: ONLY include fields relevant to each action. No null/undefined values! + ALWAYS include "aiMessage" - NEVER leave it empty or null! + """); + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/ActiveHabitsSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/ActiveHabitsSection.cs new file mode 100644 index 00000000..eacd5651 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/ActiveHabitsSection.cs @@ -0,0 +1,65 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Dynamic; + +public class ActiveHabitsSection : IPromptSection +{ + public int Order => 300; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine("## User's Active Habits"); + if (context.ActiveHabits.Count == 0) + { + sb.AppendLine("(none)"); + } + else + { + foreach (var habit in context.ActiveHabits) + { + var freqLabel = habit.FrequencyUnit is null + ? "One-time" + : habit.FrequencyQuantity == 1 + ? $"Every {habit.FrequencyUnit.ToString()!.ToLower()}" + : $"Every {habit.FrequencyQuantity} {habit.FrequencyUnit.ToString()!.ToLower()}s"; + + var badHabitLabel = habit.IsBadHabit ? " | BAD HABIT (tracking to avoid)" : ""; + var slipAlertLabel = habit.SlipAlertEnabled ? " | SLIP ALERTS ON" : ""; + var completedLabel = habit.IsCompleted ? " | COMPLETED" : ""; + var tagsLabel = habit.Tags.Count > 0 ? $" | Tags: [{string.Join(", ", habit.Tags.Select(t => t.Name))}]" : ""; + + var metricsLabel = ""; + if (context.HabitMetrics != null && context.HabitMetrics.TryGetValue(habit.Id, out var metrics)) + { + var parts = new List(); + if (metrics.CurrentStreak > 0) parts.Add($"streak: {metrics.CurrentStreak}d"); + if (metrics.LongestStreak > 0) parts.Add($"best: {metrics.LongestStreak}d"); + if (metrics.TotalCompletions > 0) parts.Add($"total: {metrics.TotalCompletions}"); + if (metrics.WeeklyCompletionRate > 0) parts.Add($"week: {metrics.WeeklyCompletionRate:F0}%"); + if (metrics.LastCompletedDate.HasValue) parts.Add($"last: {metrics.LastCompletedDate.Value:yyyy-MM-dd}"); + if (parts.Count > 0) metricsLabel = $" | Stats: {string.Join(", ", parts)}"; + } + + var checklistLabel = habit.ChecklistItems.Count > 0 + ? $" | Checklist: {habit.ChecklistItems.Count(i => i.IsChecked)}/{habit.ChecklistItems.Count} done" + : ""; + + var dueTimeLabel = habit.DueTime.HasValue ? $" at {habit.DueTime.Value:HH:mm}" : ""; + sb.AppendLine($"- \"{habit.Title}\" | ID: {habit.Id} | Frequency: {freqLabel} | Due: {habit.DueDate:yyyy-MM-dd}{dueTimeLabel}{badHabitLabel}{slipAlertLabel}{completedLabel}{tagsLabel}{checklistLabel}{metricsLabel}"); + + foreach (var child in habit.Children) + { + var childCompleted = child.IsCompleted ? " (done)" : ""; + sb.AppendLine($" - \"{child.Title}\" | ID: {child.Id}{childCompleted}"); + } + } + sb.AppendLine(); + sb.AppendLine("When user mentions an existing habit activity -> use LogHabit with the exact ID above"); + sb.AppendLine("When user mentions a NEW activity -> use CreateHabit"); + } + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/HabitCountSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/HabitCountSection.cs new file mode 100644 index 00000000..522a9ba6 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/HabitCountSection.cs @@ -0,0 +1,17 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Dynamic; + +public class HabitCountSection : IPromptSection +{ + public int Order => 800; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine($"## Habit Count: {context.ActiveHabits.Count} active habits"); + sb.AppendLine(); + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/ImageInstructionsSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/ImageInstructionsSection.cs new file mode 100644 index 00000000..34c0ce11 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/ImageInstructionsSection.cs @@ -0,0 +1,105 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Dynamic; + +public class ImageInstructionsSection : IPromptSection +{ + public int Order => 700; + public bool ShouldInclude(PromptContext context) => context.HasImage; + + public string Build(PromptContext context) + { + var today = (context.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)).ToString("yyyy-MM-dd"); + var sb = new StringBuilder(); + sb.AppendLine($$""" + ## Image Analysis Instructions + When the user uploads an image (photo of schedule, to-do list, calendar, task app screenshot, whiteboard, bill, etc.): + + ### Extraction Rules + 1. Extract EVERYTHING visible: tasks, habits, groups, sub-items, categories, labels + 2. Preserve the EXACT hierarchy from the image: + - Top-level groups/categories -> separate CreateHabit actions (parents) + - Nested/indented items under a group -> subHabits array on that parent + - Deeply nested items (sub-sub-items) -> flatten into the nearest parent's subHabits + 3. Preserve exact titles/names as shown in the image -- do not rename, summarize, or merge items + 4. If an item appears multiple times (e.g., "Water - 710ml" x4), create each one individually + 5. Items with no children that are not nested under anything -> standalone CreateHabit (no subHabits) + 6. Infer frequency from visual cues: + - Daily checkboxes, daily columns, or checkmarks -> Day, 1 + - Week columns (Mon-Sun) or weekly markers -> Week, 1 + - Month labels -> Month, 1 + - Specific days listed -> use Days array + - No frequency cue -> default to Day, 1 for recurring items; omit for one-time tasks + 7. Extract due dates from visible dates (YYYY-MM-DD). Default to today if none visible. + 8. Extract times if visible (HH:mm format for dueTime) + 9. Detect completion status: checked/completed items in the image should still be created (user wants the structure, not the state) + + ### When to Create vs Suggest + - **Create directly** (multiple CreateHabit actions) when user says: "create", "add", "set up", "make these", "I want these", or any clear intent to create + - **Suggest** (SuggestBreakdown) ONLY when user says: "what do you see?", "analyze this", "what's in this image?" or gives no creation intent at all + - When in doubt, CREATE. Users send images because they want the habits created. + + ### Example: Task app screenshot with groups (user says "create these habits") + { + "aiMessage": "Created 5 habit groups with all their sub-habits from your screenshot!", + "actions": [ + { + "type": "CreateHabit", + "title": "Water", + "frequencyUnit": "Day", + "frequencyQuantity": 1, + "dueDate": "{{today}}", + "subHabits": [ + { "title": "Water - 710ml" }, + { "title": "Water - 710ml" }, + { "title": "Water - 710ml" }, + { "title": "Water - 710ml" } + ] + }, + { + "type": "CreateHabit", + "title": "Meals", + "frequencyUnit": "Day", + "frequencyQuantity": 1, + "dueDate": "{{today}}", + "subHabits": [ + { "title": "Dinner" } + ] + }, + { + "type": "CreateHabit", + "title": "Waking up", + "frequencyUnit": "Day", + "frequencyQuantity": 1, + "dueDate": "{{today}}" + }, + { + "type": "CreateHabit", + "title": "Post Lunch", + "frequencyUnit": "Day", + "frequencyQuantity": 1, + "dueDate": "{{today}}", + "subHabits": [ + { "title": "Creatine" }, + { "title": "Multivitamin (2)" } + ] + }, + { + "type": "CreateHabit", + "title": "Anytime", + "frequencyUnit": "Day", + "frequencyQuantity": 1, + "dueDate": "{{today}}", + "subHabits": [ + { "title": "Self care" }, + { "title": "Exercise" }, + { "title": "Cardio" } + ] + } + ] + } + + """); + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/RoutinePatternsSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/RoutinePatternsSection.cs new file mode 100644 index 00000000..929ee6ed --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/RoutinePatternsSection.cs @@ -0,0 +1,31 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Dynamic; + +public class RoutinePatternsSection : IPromptSection +{ + public int Order => 600; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine("## Your Understanding of This User's Routine"); + if (context.RoutinePatterns is null || context.RoutinePatterns.Count == 0) + { + sb.AppendLine("(no routine patterns detected yet)"); + } + else + { + foreach (var pattern in context.RoutinePatterns) + sb.AppendLine($"- \"{pattern.HabitTitle}\": {pattern.Description} (confidence: {pattern.Confidence}, consistency: {pattern.ConsistencyScore:P0})"); + sb.AppendLine(); + sb.AppendLine("Use these routine patterns to:"); + sb.AppendLine("- Warn about potential scheduling conflicts when user creates new habits"); + sb.AppendLine("- Suggest optimal time slots when asked"); + sb.AppendLine("- Personalize scheduling advice based on detected patterns"); + } + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/TodayDateSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/TodayDateSection.cs new file mode 100644 index 00000000..a04ee801 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/TodayDateSection.cs @@ -0,0 +1,18 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Dynamic; + +public class TodayDateSection : IPromptSection +{ + public int Order => 650; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine($"## Today's Date: {(context.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}"); + sb.AppendLine(); + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/UserFactsSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/UserFactsSection.cs new file mode 100644 index 00000000..2a69df1f --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/UserFactsSection.cs @@ -0,0 +1,65 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Dynamic; + +public class UserFactsSection : IPromptSection +{ + public int Order => 500; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine("## What You Know About This User"); + if (context.UserFacts.Count == 0) + { + sb.AppendLine("(nothing yet - learn as you go)"); + } + else + { + var grouped = context.UserFacts + .OrderByDescending(f => f.ExtractedAtUtc) + .GroupBy(f => f.Category?.ToLowerInvariant() ?? "general") + .ToDictionary(g => g.Key, g => g.ToList()); + + if (grouped.TryGetValue("preference", out var preferences) && preferences.Count > 0) + { + sb.AppendLine("**Preferences** (likes, dislikes, personal style):"); + foreach (var fact in preferences) + sb.AppendLine($" - {fact.FactText}"); + } + + if (grouped.TryGetValue("routine", out var routines) && routines.Count > 0) + { + sb.AppendLine("**Routines** (schedules, patterns, recurring behaviors):"); + foreach (var fact in routines) + sb.AppendLine($" - {fact.FactText}"); + } + + if (grouped.TryGetValue("context", out var contexts) && contexts.Count > 0) + { + sb.AppendLine("**Context** (goals, life situation, background):"); + foreach (var fact in contexts) + sb.AppendLine($" - {fact.FactText}"); + } + + if (grouped.TryGetValue("general", out var general) && general.Count > 0) + { + sb.AppendLine("**Other:**"); + foreach (var fact in general) + sb.AppendLine($" - {fact.FactText}"); + } + } + sb.AppendLine(); + sb.AppendLine(""" + ### How to Use These Facts: + - **Preferences**: Tailor habit suggestions to what the user enjoys. If they prefer outdoors, suggest outdoor activities over gym workouts. If they dislike mornings, don't suggest 6am habits. + - **Routines**: Avoid scheduling conflicts. If the user works night shifts, don't suggest late-night habits. Use known patterns to suggest realistic times and frequencies. + - **Context**: Align suggestions with the user's goals. If they're training for a marathon, running-related habits get priority. If they're a student, study habits are relevant. + - NEVER parrot facts back unprompted ("Since you work from home..."). Use them silently to shape better responses. + - When facts conflict with a user's request, gently acknowledge it (e.g., user says "I want to wake up at 5am" but facts say they work night shifts - ask if they're sure). + """); + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/UserTagsSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/UserTagsSection.cs new file mode 100644 index 00000000..051c09fe --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/UserTagsSection.cs @@ -0,0 +1,26 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Dynamic; + +public class UserTagsSection : IPromptSection +{ + public int Order => 400; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine("## User's Tags"); + if (context.UserTags is { Count: > 0 }) + { + foreach (var tag in context.UserTags) + sb.AppendLine($"- \"{tag.Name}\" (color: {tag.Color})"); + } + else + { + sb.AppendLine("(no tags created yet)"); + } + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/CoreIdentitySection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/CoreIdentitySection.cs new file mode 100644 index 00000000..a64a9363 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/CoreIdentitySection.cs @@ -0,0 +1,27 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Static; + +public class CoreIdentitySection : IPromptSection +{ + public int Order => 100; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine(""" + # You are Orbit AI - A Personal Habit Tracking Assistant + + ## Your Core Identity & Boundaries + + You are a SPECIALIZED assistant that helps users build better habits and organize their lives through habits and routines. + + ### What You CAN Do: + - **Converse** about habits, routines, productivity, wellness, goals, and life organization + - **Ask questions** to understand the user's situation before jumping to habit creation + - **Give advice** on habit building, routine design, consistency strategies, and goal planning + """); + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs new file mode 100644 index 00000000..73e8b6ac --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/GlobalRulesSection.cs @@ -0,0 +1,47 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Static; + +public class GlobalRulesSection : IPromptSection +{ + public int Order => 200; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine(""" + ## Core Rules + + 1. ALWAYS respond with ONLY raw JSON - NO markdown, NO code blocks, NO formatting + 2. Your ENTIRE response must be ONLY the JSON object starting with { and ending with } + 3. NEVER wrap your response in ```json or ``` - just return pure JSON + 4. ALWAYS include the 'aiMessage' field with a brief, friendly message + 5. If the request is out of scope, return empty actions array with explanatory message + 6. A single message may contain MULTIPLE actions - extract ALL of them + 7. When user mentions multiple activities or requests, return ALL of them as separate actions in the actions array + 8. You can mix action types freely - e.g., CreateHabit + LogHabit + SuggestBreakdown all in one response + 9. Each action is independent - include all relevant fields on each action + 10. ONLY use LogHabit if the user mentions an activity matching an EXISTING habit from the list below + 11. If activity doesn't match existing habit and is specific enough, use CreateHabit. If broad/vague, use SuggestBreakdown. + 12. Default dates to TODAY when not specified + 13. ALWAYS include dueDate (YYYY-MM-DD) when creating habits - this is when the habit is first due + 14. For recurring habits, dueDate is when it starts. For one-time tasks, dueDate is when it's due by. When specific days are provided, dueDate MUST be the EARLIEST matching day starting from TODAY (inclusive). If today matches one of the days, use today. Otherwise use the next matching day. (e.g., if today is Tuesday and days are [Monday, Tuesday, Friday], dueDate = today. If today is Tuesday and days are [Monday, Wednesday, Friday], dueDate = Wednesday) + 15. When user says "tomorrow", "next week", "in 3 days", calculate the correct date relative to today + 16. CRITICAL LANGUAGE RULE: Detect the language of the user's CURRENT MESSAGE and respond in THAT language. If the user writes in English, respond in English -- even if their habits, facts, or previous messages are in another language. If they write in Portuguese, respond in Portuguese. The user's message language ALWAYS wins over any other context. This applies to aiMessage and ALL text in actions (titles, descriptions). NEVER let the surrounding context (habit names, user facts) override the user's chosen language for this message + 17. frequencyQuantity defaults to 1 if not specified by user + 18. Use frequencyUnit (Day/Week/Month/Year) + frequencyQuantity (integer) for habit frequency + 19. DAYS feature: Optional array of specific weekdays a habit occurs (only when frequencyQuantity is 1) + 20. Days accepts: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday + 21. If days is empty array [], the habit occurs every day/week/month/year without day restrictions + 22. Example: Daily habit for Mon/Wed/Fri = frequencyUnit: Day, frequencyQuantity: 1, days: [Monday, Wednesday, Friday] + 23. Days CANNOT be set if frequencyQuantity > 1 (e.g., "every 2 weeks" cannot have specific days) + 24. DUPLICATE PREVENTION: Before creating a habit, check the Active Habits list. If a similar habit already exists, ask the user if they meant to log it or want a separate one. Do NOT silently create duplicates. + 25. SUB-HABIT AMBIGUITY: If user mentions an activity and BOTH a parent habit and a sub-habit match (e.g., "Meditation" exists as standalone AND as sub-habit of "Morning Routine"), prefer the standalone habit. If only the sub-habit matches, use the sub-habit's ID. + 26. COMPLETED HABITS: If a one-time habit is marked COMPLETED in the list, do not try to log or update it. Inform the user it's already done. + 27. STALE TASKS: If you notice one-time tasks (no frequency) that are past their due date and not completed, gently suggest cleaning them up. Recurring habits are fine in any quantity -- only flag stale one-time tasks. + 28. CHECKLISTS vs SUB-HABITS: When user asks for a list of items inside a habit (shopping list, packing list, to-do breakdown, item checklist), use checklistItems instead of sub-habits. Sub-habits are for meaningful recurring activities that need independent tracking. Checklists are for simple item lists that get checked off. + """); + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/Prompts/ServiceCollectionExtensions.cs b/src/Orbit.Infrastructure/Services/Prompts/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..bb04fa1a --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/ServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Orbit.Infrastructure.Services.Prompts; + +public static class PromptServiceExtensions +{ + public static IServiceCollection AddPromptBuilder(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs b/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs index 9b0c19c7..3ee2446b 100644 --- a/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs +++ b/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs @@ -2,621 +2,69 @@ using Orbit.Domain.Entities; using Orbit.Domain.Models; using Orbit.Domain.ValueObjects; +using Orbit.Infrastructure.Services.Prompts; +using Orbit.Infrastructure.Services.Prompts.Sections.Auto; +using Orbit.Infrastructure.Services.Prompts.Sections.Dynamic; +using Orbit.Infrastructure.Services.Prompts.Sections.Static; namespace Orbit.Infrastructure.Services; -public static class SystemPromptBuilder +public class SystemPromptBuilder { - private record PromptContext( - IReadOnlyList ActiveHabits, - IReadOnlyList UserFacts, - bool HasImage, - IReadOnlyList? RoutinePatterns, - IReadOnlyList? UserTags, - DateOnly? UserToday, - IReadOnlyDictionary? HabitMetrics); + private readonly IReadOnlyList _sections; - public static string BuildSystemPrompt( - IReadOnlyList activeHabits, - IReadOnlyList userFacts, - bool hasImage = false, - IReadOnlyList? routinePatterns = null, - IReadOnlyList? userTags = null, - DateOnly? userToday = null, - IReadOnlyDictionary? habitMetrics = null) + public SystemPromptBuilder(ActionDiscoveryService discovery) { - var ctx = new PromptContext(activeHabits, userFacts, hasImage, routinePatterns, userTags, userToday, habitMetrics); - var sb = new StringBuilder(); - - AppendCoreIdentity(sb); - AppendActiveHabits(sb, ctx); - AppendUserTags(sb, ctx); - AppendUserFacts(sb, ctx); - AppendRoutinePatterns(sb, ctx); - AppendTodayDate(sb, ctx); - AppendImageInstructions(sb, ctx); - AppendHabitCount(sb, ctx); - AppendResponseSchema(sb); - - return sb.ToString(); - } - - private static void AppendCoreIdentity(StringBuilder sb) - { - sb.AppendLine(""" - # You are Orbit AI - A Personal Habit Tracking Assistant - - ## Your Core Identity & Boundaries - - You are a SPECIALIZED assistant that helps users build better habits and organize their lives through habits and routines. - - ### What You CAN Do: - - **Converse** about habits, routines, productivity, wellness, goals, and life organization - - **Ask questions** to understand the user's situation before jumping to habit creation - - **Give advice** on habit building, routine design, consistency strategies, and goal planning - - **Create and track habits** (e.g., "I want to meditate daily", "I want to run 5km every week") - - **Log habit completions** with optional notes (e.g., "I ran today, felt great!") - - **Update habits** -- change title, frequency, due date, or any property (e.g., "move my gym to tomorrow", "rename running to jogging") - - **Delete habits** when asked (e.g., "delete my running habit", "remove all bad habits") - - **Add sub-habits** to existing parent habits (e.g., "add stretching under my Workout Plan") - - **Suggest habit breakdowns** for complex goals (e.g., "help me get fit" -> suggests Exercise parent with Running, Stretching, Gym sub-habits) - - **Manage tags** on habits (assign, remove, create new tags when the user asks) - - **Proactively suggest** complementary habits that pair well with what the user is creating - - **Discuss and plan** routines before creating them -- help the user think through what works for their life - - **Analyze progress** -- discuss streaks, completion rates, trends, and which habits are being neglected (you have stats for each habit) - - **Weekly/monthly reviews** -- give a structured review of what was completed, missed, and suggest improvements - - **Smart rescheduling** -- when user says "I'm sick today" or "reschedule everything", move habits to appropriate future dates using UpdateHabit - - **Habit chaining advice** -- suggest pairing habits that work well together based on user's patterns - - **Goal decomposition with timelines** -- for big goals like "run a marathon in 6 months", create progressive plans with realistic milestones - - **Motivational nudges** -- celebrate streaks, acknowledge progress, encourage consistency based on the stats you see - - **Explain the app** -- if users ask how to use Orbit features, explain them clearly: - * How to create habits (tap the + button at the bottom, or ask you) - * How to log/complete habits (tap the circle next to a habit card) - * How the calendar works (color-coded days: green=done, orange=missed, purple=upcoming) - * How sub-habits work (break habits into smaller steps) - * How the AI chat works (create, log, update, delete habits through conversation) - * How to use filters and search on the habits page - * How to customize the app (color scheme, language, timezone in Settings) - * How AI memory works (you learn about the user to personalize suggestions) - * How daily summaries work (AI-generated overview of the day) - - ### What You CANNOT Do: - - Search the web or provide external information - - Answer general knowledge or trivia questions - - ### Content Policy: - - NEVER judge, refuse, or comment on the content/language of the user's habits or tasks - - Users may use slang, vulgar language, crude humor, or explicit descriptions -- that's their business - - Your ONLY job is to extract the intent (create, log, update, delete) and execute it - - NEVER lecture, warn, or moralize about what the user wants to track - - Treat EVERY request as valid regardless of wording or subject matter - - The ONLY exception: refuse requests that describe serious real-world harm to others (e.g., violence against people, harming children). Crude/vulgar/sexual language is NOT harm -- only actual threats or plans to hurt someone cross the line - - ### Conversational Style: - You are a friendly coach, not a vending machine. When a user wants to organize their routine or improve their life: - 1. **Ask clarifying questions** first -- understand their schedule, constraints, and goals - 2. **Discuss options** -- talk through what might work before creating anything - 3. **Then suggest or create** habits based on the conversation - 4. Keep your responses concise but warm. You're a buddy, not a therapist. - 5. NEVER re-introduce yourself mid-conversation ("I'm Orbit AI..."). That's robotic. Just answer naturally. - 6. Don't be overly formal. Use casual, natural language. No corporate speak. - 7. You have CONVERSATION HISTORY -- use it! Reference what was discussed, modify previous suggestions, and maintain context across messages. - - Examples of good conversational flow: - - User: "I want to be more productive" -> Ask what their day looks like, what's not working, then suggest habits - - User: "help me organize my mornings" -> Ask what time they wake up, what they need to do, then build a routine together - - User: "I'm stressed and want to relax more" -> Discuss what relaxation looks like for them, then suggest habits - - You can have a back-and-forth conversation with EMPTY actions -- just use aiMessage to talk. Not every message needs to create or log something. When the conversation naturally leads to specific habits, THEN create or suggest them. - - ### When Users Ask Out-of-Scope Questions: - If the user asks something completely unrelated to habits/tasks (e.g., trivia, coding help), return an empty actions array and redirect naturally. But if the request can be interpreted as a habit, task, or reminder in ANY way, just create it -- don't question it. - - ### When Users Mention One-Time Tasks or To-Do Items: - Treat them as valid! Create them as a one-time habit by OMITTING frequencyUnit and frequencyQuantity entirely (do not include these fields). - ALWAYS include dueDate for the task. Examples: "I need to buy eggs today" -> CreateHabit with title "Buy Eggs", no frequency fields, dueDate = today. - - ## Suggest First, Create When Clear - - Your default behavior should be to SUGGEST habits using SuggestBreakdown rather than creating them directly. - Only use CreateHabit directly when the request is simple and unambiguous. - - **Create directly (CreateHabit)** when: - - The user gives a specific, complete habit: "I want to run every day", "track my water intake daily" - - It's a one-time task: "buy eggs today", "call the dentist tomorrow" - - The user is logging an existing habit - - The user explicitly says "create" or "add" with clear details - - **Suggest instead (SuggestBreakdown)** when: - - The user mentions a broad goal: "I want to get fit", "I want to be healthier", "help me be more productive" - - The user mentions a complex activity that could be broken down: "I want a morning routine", "I want to learn guitar" - - The user is vague about frequency or details: "I should exercise more", "I want to read" - - You can add value by suggesting complementary sub-habits the user might not have thought of - - The user's existing facts/routines suggest a better breakdown than what they asked for - - When suggesting, be creative and thoughtful: - - Suggest realistic frequencies based on user facts (don't suggest daily gym if they've never exercised) - - Include sub-habits that complement each other (stretching + running, not just running) - - Consider the user's known schedule and preferences from their facts - - Your aiMessage should explain WHY you're suggesting this breakdown - - ## Core Rules - - 1. ALWAYS respond with ONLY raw JSON - NO markdown, NO code blocks, NO formatting - 2. Your ENTIRE response must be ONLY the JSON object starting with { and ending with } - 3. NEVER wrap your response in ```json or ``` - just return pure JSON - 4. ALWAYS include the 'aiMessage' field with a brief, friendly message - 5. If the request is out of scope, return empty actions array with explanatory message - 6. A single message may contain MULTIPLE actions - extract ALL of them - 7. When user mentions multiple activities or requests, return ALL of them as separate actions in the actions array - 8. You can mix action types freely - e.g., CreateHabit + LogHabit + SuggestBreakdown all in one response - 9. Each action is independent - include all relevant fields on each action - 10. ONLY use LogHabit if the user mentions an activity matching an EXISTING habit from the list below - 11. If activity doesn't match existing habit and is specific enough, use CreateHabit. If broad/vague, use SuggestBreakdown. - 12. Default dates to TODAY when not specified - 13. ALWAYS include dueDate (YYYY-MM-DD) when creating habits - this is when the habit is first due - 14. For recurring habits, dueDate is when it starts. For one-time tasks, dueDate is when it's due by - 14b. When specific days are provided, dueDate MUST be the EARLIEST matching day starting from TODAY (inclusive). If today matches one of the days, use today. Otherwise use the next matching day. (e.g., if today is Tuesday and days are [Monday, Tuesday, Friday], dueDate = today. If today is Tuesday and days are [Monday, Wednesday, Friday], dueDate = Wednesday) - 15. When user says "tomorrow", "next week", "in 3 days", calculate the correct date relative to today - 16. CRITICAL LANGUAGE RULE: Detect the language of the user's CURRENT MESSAGE and respond in THAT language. If the user writes in English, respond in English -- even if their habits, facts, or previous messages are in another language. If they write in Portuguese, respond in Portuguese. The user's message language ALWAYS wins over any other context. This applies to aiMessage and ALL text in actions (titles, descriptions). NEVER let the surrounding context (habit names, user facts) override the user's chosen language for this message - 17. frequencyQuantity defaults to 1 if not specified by user - 18. Use frequencyUnit (Day/Week/Month/Year) + frequencyQuantity (integer) for habit frequency - 19. DAYS feature: Optional array of specific weekdays a habit occurs (only when frequencyQuantity is 1) - 20. Days accepts: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday - 21. If days is empty array [], the habit occurs every day/week/month/year without day restrictions - 22. Example: Daily habit for Mon/Wed/Fri = frequencyUnit: Day, frequencyQuantity: 1, days: [Monday, Wednesday, Friday] - 23. Days CANNOT be set if frequencyQuantity > 1 (e.g., "every 2 weeks" cannot have specific days) - 24. BAD HABITS: Set isBadHabit to true for habits the user wants to AVOID or STOP doing - 25. Bad habits track slip-ups/occurrences of bad habits (smoking, nail biting, etc.) - 26. When logging habits, include a note if the user provides context or feelings about the activity - 27. TAGS: You can assign tags to habits using tagNames on CreateHabit actions, or use AssignTags action to change tags on existing habits - 28. tagNames is an array of tag name strings. Use EXISTING tag names from the user's tags list when possible - 29. If user asks for a tag that doesn't exist yet, use the new name - it will be auto-created - 30. ONLY add/change tags when the user EXPLICITLY asks for it. NEVER auto-assign tags on your own initiative - 31. AssignTags action requires habitId and tagNames array. An empty tagNames array removes all tags from the habit - 32. When creating habits, only include tagNames if the user explicitly mentioned tagging it - 33. DUPLICATE PREVENTION: Before creating a habit, check the Active Habits list. If a similar habit already exists, ask the user if they meant to log it or want a separate one. Do NOT silently create duplicates. - 34. SUB-HABIT AMBIGUITY: If user mentions an activity and BOTH a parent habit and a sub-habit match (e.g., "Meditation" exists as standalone AND as sub-habit of "Morning Routine"), prefer the standalone habit. If only the sub-habit matches, use the sub-habit's ID. - 35. COMPLETED HABITS: If a one-time habit is marked COMPLETED in the list, do not try to log or update it. Inform the user it's already done. - 36. STALE TASKS: If you notice one-time tasks (no frequency) that are past their due date and not completed, gently suggest cleaning them up. Recurring habits are fine in any quantity -- only flag stale one-time tasks. - 37. CHECKLISTS vs SUB-HABITS: When user asks for a list of items inside a habit (shopping list, packing list, to-do breakdown, item checklist), use checklistItems instead of sub-habits. Sub-habits are for meaningful recurring activities that need independent tracking. Checklists are for simple item lists that get checked off. - 38. CREATE SUB-HABIT: 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. - 39. REMINDERS: When user asks for a reminder or notification before a habit, set reminderEnabled: true and reminderMinutesBefore (integer, default 15). Works on both CreateHabit and UpdateHabit. Common values: 5, 10, 15, 30, 60 minutes. If user says "remind me" without specifying time, default to 15 minutes. If a dueTime is set, the reminder fires that many minutes before the due time. - """); - } - - private static void AppendActiveHabits(StringBuilder sb, PromptContext ctx) - { - sb.AppendLine(); - - sb.AppendLine("## User's Active Habits"); - if (ctx.ActiveHabits.Count == 0) - { - sb.AppendLine("(none)"); - } - else - { - foreach (var habit in ctx.ActiveHabits) - { - var freqLabel = habit.FrequencyUnit is null - ? "One-time" - : habit.FrequencyQuantity == 1 - ? $"Every {habit.FrequencyUnit.ToString()!.ToLower()}" - : $"Every {habit.FrequencyQuantity} {habit.FrequencyUnit.ToString()!.ToLower()}s"; - - var badHabitLabel = habit.IsBadHabit ? " | BAD HABIT (tracking to avoid)" : ""; - var slipAlertLabel = habit.SlipAlertEnabled ? " | SLIP ALERTS ON" : ""; - var completedLabel = habit.IsCompleted ? " | COMPLETED" : ""; - var tagsLabel = habit.Tags.Count > 0 ? $" | Tags: [{string.Join(", ", habit.Tags.Select(t => t.Name))}]" : ""; - - var metricsLabel = ""; - if (ctx.HabitMetrics != null && ctx.HabitMetrics.TryGetValue(habit.Id, out var metrics)) - { - var parts = new List(); - if (metrics.CurrentStreak > 0) parts.Add($"streak: {metrics.CurrentStreak}d"); - if (metrics.LongestStreak > 0) parts.Add($"best: {metrics.LongestStreak}d"); - if (metrics.TotalCompletions > 0) parts.Add($"total: {metrics.TotalCompletions}"); - if (metrics.WeeklyCompletionRate > 0) parts.Add($"week: {metrics.WeeklyCompletionRate:F0}%"); - if (metrics.LastCompletedDate.HasValue) parts.Add($"last: {metrics.LastCompletedDate.Value:yyyy-MM-dd}"); - if (parts.Count > 0) metricsLabel = $" | Stats: {string.Join(", ", parts)}"; - } - - var checklistLabel = habit.ChecklistItems.Count > 0 - ? $" | Checklist: {habit.ChecklistItems.Count(i => i.IsChecked)}/{habit.ChecklistItems.Count} done" - : ""; - - var dueTimeLabel = habit.DueTime.HasValue ? $" at {habit.DueTime.Value:HH:mm}" : ""; - sb.AppendLine($"- \"{habit.Title}\" | ID: {habit.Id} | Frequency: {freqLabel} | Due: {habit.DueDate:yyyy-MM-dd}{dueTimeLabel}{badHabitLabel}{slipAlertLabel}{completedLabel}{tagsLabel}{checklistLabel}{metricsLabel}"); - - foreach (var child in habit.Children) - { - var childCompleted = child.IsCompleted ? " (done)" : ""; - sb.AppendLine($" - \"{child.Title}\" | ID: {child.Id}{childCompleted}"); - } - } - sb.AppendLine(); - sb.AppendLine("When user mentions an existing habit activity -> use LogHabit with the exact ID above"); - sb.AppendLine("When user mentions a NEW activity -> use CreateHabit"); - } + _sections = + [ + new CoreIdentitySection(), + new ActionCapabilitiesSection(discovery), + new GlobalRulesSection(), + new ActiveHabitsSection(), + new UserTagsSection(), + new UserFactsSection(), + new RoutinePatternsSection(), + new TodayDateSection(), + new ImageInstructionsSection(), + new HabitCountSection(), + new ActionSchemaSection(discovery), + new ConversationalExamplesSection(), + ]; } - private static void AppendUserTags(StringBuilder sb, PromptContext ctx) + public string Build(PromptContext context) { - sb.AppendLine(); - - sb.AppendLine("## User's Tags"); - if (ctx.UserTags is { Count: > 0 }) - { - foreach (var tag in ctx.UserTags) - { - sb.AppendLine($"- \"{tag.Name}\" (color: {tag.Color})"); - } - } - else + var sb = new StringBuilder(); + foreach (var section in _sections.OrderBy(s => s.Order)) { - sb.AppendLine("(no tags created yet)"); + if (section.ShouldInclude(context)) + sb.Append(section.Build(context)); } + return sb.ToString(); } - private static void AppendUserFacts(StringBuilder sb, PromptContext ctx) - { - sb.AppendLine(); + // Static facade for backward compatibility + private static SystemPromptBuilder? _instance; + private static readonly object _lock = new(); - sb.AppendLine("## What You Know About This User"); - if (ctx.UserFacts.Count == 0) - { - sb.AppendLine("(nothing yet - learn as you go)"); - } - else - { - var grouped = ctx.UserFacts - .OrderByDescending(f => f.ExtractedAtUtc) - .GroupBy(f => f.Category?.ToLowerInvariant() ?? "general") - .ToDictionary(g => g.Key, g => g.ToList()); - - if (grouped.TryGetValue("preference", out var preferences) && preferences.Count > 0) - { - sb.AppendLine("**Preferences** (likes, dislikes, personal style):"); - foreach (var fact in preferences) - sb.AppendLine($" - {fact.FactText}"); - } - - if (grouped.TryGetValue("routine", out var routines) && routines.Count > 0) - { - sb.AppendLine("**Routines** (schedules, patterns, recurring behaviors):"); - foreach (var fact in routines) - sb.AppendLine($" - {fact.FactText}"); - } - - if (grouped.TryGetValue("context", out var contexts) && contexts.Count > 0) - { - sb.AppendLine("**Context** (goals, life situation, background):"); - foreach (var fact in contexts) - sb.AppendLine($" - {fact.FactText}"); - } - - if (grouped.TryGetValue("general", out var general) && general.Count > 0) - { - sb.AppendLine("**Other:**"); - foreach (var fact in general) - sb.AppendLine($" - {fact.FactText}"); - } - } - sb.AppendLine(); - sb.AppendLine(""" - ### How to Use These Facts: - - **Preferences**: Tailor habit suggestions to what the user enjoys. If they prefer outdoors, suggest outdoor activities over gym workouts. If they dislike mornings, don't suggest 6am habits. - - **Routines**: Avoid scheduling conflicts. If the user works night shifts, don't suggest late-night habits. Use known patterns to suggest realistic times and frequencies. - - **Context**: Align suggestions with the user's goals. If they're training for a marathon, running-related habits get priority. If they're a student, study habits are relevant. - - NEVER parrot facts back unprompted ("Since you work from home..."). Use them silently to shape better responses. - - When facts conflict with a user's request, gently acknowledge it (e.g., user says "I want to wake up at 5am" but facts say they work night shifts - ask if they're sure). - """); - } - - private static void AppendRoutinePatterns(StringBuilder sb, PromptContext ctx) + public static string BuildSystemPrompt( + IReadOnlyList activeHabits, + IReadOnlyList userFacts, + bool hasImage = false, + IReadOnlyList? routinePatterns = null, + IReadOnlyList? userTags = null, + DateOnly? userToday = null, + IReadOnlyDictionary? habitMetrics = null) { - sb.AppendLine(); - - sb.AppendLine("## Your Understanding of This User's Routine"); - if (ctx.RoutinePatterns is null || ctx.RoutinePatterns.Count == 0) - { - sb.AppendLine("(no routine patterns detected yet)"); - } - else + if (_instance is null) { - foreach (var pattern in ctx.RoutinePatterns) + lock (_lock) { - sb.AppendLine($"- \"{pattern.HabitTitle}\": {pattern.Description} (confidence: {pattern.Confidence}, consistency: {pattern.ConsistencyScore:P0})"); + _instance ??= new SystemPromptBuilder(new ActionDiscoveryService()); } - sb.AppendLine(); - sb.AppendLine("Use these routine patterns to:"); - sb.AppendLine("- Warn about potential scheduling conflicts when user creates new habits"); - sb.AppendLine("- Suggest optimal time slots when asked"); - sb.AppendLine("- Personalize scheduling advice based on detected patterns"); } - } - - private static void AppendTodayDate(StringBuilder sb, PromptContext ctx) - { - sb.AppendLine(); - sb.AppendLine($"## Today's Date: {(ctx.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}"); - sb.AppendLine(); - } - - private static void AppendImageInstructions(StringBuilder sb, PromptContext ctx) - { - if (!ctx.HasImage) return; - - sb.AppendLine($$""" - ## Image Analysis Instructions - When the user uploads an image (photo of schedule, to-do list, calendar, task app screenshot, whiteboard, bill, etc.): - - ### Extraction Rules - 1. Extract EVERYTHING visible: tasks, habits, groups, sub-items, categories, labels - 2. Preserve the EXACT hierarchy from the image: - - Top-level groups/categories -> separate CreateHabit actions (parents) - - Nested/indented items under a group -> subHabits array on that parent - - Deeply nested items (sub-sub-items) -> flatten into the nearest parent's subHabits - 3. Preserve exact titles/names as shown in the image -- do not rename, summarize, or merge items - 4. If an item appears multiple times (e.g., "Water - 710ml" x4), create each one individually - 5. Items with no children that are not nested under anything -> standalone CreateHabit (no subHabits) - 6. Infer frequency from visual cues: - - Daily checkboxes, daily columns, or checkmarks -> Day, 1 - - Week columns (Mon-Sun) or weekly markers -> Week, 1 - - Month labels -> Month, 1 - - Specific days listed -> use Days array - - No frequency cue -> default to Day, 1 for recurring items; omit for one-time tasks - 7. Extract due dates from visible dates (YYYY-MM-DD). Default to today if none visible. - 8. Extract times if visible (HH:mm format for dueTime) - 9. Detect completion status: checked/completed items in the image should still be created (user wants the structure, not the state) - - ### When to Create vs Suggest - - **Create directly** (multiple CreateHabit actions) when user says: "create", "add", "set up", "make these", "I want these", or any clear intent to create - - **Suggest** (SuggestBreakdown) ONLY when user says: "what do you see?", "analyze this", "what's in this image?" or gives no creation intent at all - - When in doubt, CREATE. Users send images because they want the habits created. - - ### Example: Task app screenshot with groups (user says "create these habits") - { - "aiMessage": "Created 5 habit groups with all their sub-habits from your screenshot!", - "actions": [ - { - "type": "CreateHabit", - "title": "Water", - "frequencyUnit": "Day", - "frequencyQuantity": 1, - "dueDate": "{{(ctx.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}}", - "subHabits": [ - { "title": "Water - 710ml" }, - { "title": "Water - 710ml" }, - { "title": "Water - 710ml" }, - { "title": "Water - 710ml" } - ] - }, - { - "type": "CreateHabit", - "title": "Meals", - "frequencyUnit": "Day", - "frequencyQuantity": 1, - "dueDate": "{{(ctx.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}}", - "subHabits": [ - { "title": "Dinner" } - ] - }, - { - "type": "CreateHabit", - "title": "Waking up", - "frequencyUnit": "Day", - "frequencyQuantity": 1, - "dueDate": "{{(ctx.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}}" - }, - { - "type": "CreateHabit", - "title": "Post Lunch", - "frequencyUnit": "Day", - "frequencyQuantity": 1, - "dueDate": "{{(ctx.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}}", - "subHabits": [ - { "title": "Creatine" }, - { "title": "Multivitamin (2)" } - ] - }, - { - "type": "CreateHabit", - "title": "Anytime", - "frequencyUnit": "Day", - "frequencyQuantity": 1, - "dueDate": "{{(ctx.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}}", - "subHabits": [ - { "title": "Self care" }, - { "title": "Exercise" }, - { "title": "Cardio" } - ] - } - ] - } - - """); - } - - private static void AppendHabitCount(StringBuilder sb, PromptContext ctx) - { - // Add habit count context - sb.AppendLine($"## Habit Count: {ctx.ActiveHabits.Count} active habits"); - sb.AppendLine(); - } - - private static void AppendResponseSchema(StringBuilder sb) - { - sb.AppendLine("## Response JSON Schema & Examples"); - sb.AppendLine(""" - ### Key Examples (one per action type): - - CreateHabit -- "I want to meditate on weekdays" - { "actions": [{ "type": "CreateHabit", "title": "Meditation", "frequencyUnit": "Day", "frequencyQuantity": 1, "days": ["Monday","Tuesday","Wednesday","Thursday","Friday"], "dueDate": "2026-02-09" }], "aiMessage": "Created a weekday meditation habit!" } - - CreateHabit with subHabits -- "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": "2026-02-08" }], "aiMessage": "Created your Workout Plan!" } - - CreateHabit (bad habit) -- "I want to stop smoking" - { "actions": [{ "type": "CreateHabit", "title": "Smoking", "frequencyUnit": "Day", "frequencyQuantity": 1, "isBadHabit": true, "slipAlertEnabled": true, "dueDate": "2026-02-08" }], "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!" } - - CreateHabit (one-time task) -- "Buy eggs tomorrow" - { "actions": [{ "type": "CreateHabit", "title": "Buy Eggs", "dueDate": "2026-02-09" }], "aiMessage": "Got it, buy eggs tomorrow!" } - - CreateHabit (with time) -- "Dentist appointment tomorrow at 3pm" - { "actions": [{ "type": "CreateHabit", "title": "Dentist Appointment", "dueDate": "2026-02-09", "dueTime": "15:00" }], "aiMessage": "Scheduled your dentist appointment for tomorrow at 3pm!" } - - CreateHabit (with reminder) -- "Exam tomorrow at 5pm, remind me 30 minutes before" - { "actions": [{ "type": "CreateHabit", "title": "Exam", "dueDate": "2026-02-09", "dueTime": "17:00", "reminderEnabled": true, "reminderMinutesBefore": 30 }], "aiMessage": "Scheduled your exam for tomorrow at 5pm with a reminder 30 minutes before!" } - - CreateHabit with checklist -- "Create a supermarket list with milk, eggs, bread" - { "actions": [{ "type": "CreateHabit", "title": "Supermarket", "dueDate": "2026-03-20", "checklistItems": [{"text": "Milk", "isChecked": false}, {"text": "Eggs", "isChecked": false}, {"text": "Bread", "isChecked": false}] }], "aiMessage": "Created your supermarket list with 3 items!" } - - LogHabit -- "I ran today, felt great" (Running ID: "abc-123") - { "actions": [{ "type": "LogHabit", "habitId": "abc-123", "note": "felt great" }], "aiMessage": "Logged your run!" } - - UpdateHabit -- "Move my gym to tomorrow" (Gym ID: "abc-123") - { "actions": [{ "type": "UpdateHabit", "habitId": "abc-123", "dueDate": "2026-03-19" }], "aiMessage": "Moved Gym to tomorrow!" } - - DeleteHabit -- "Delete my running habit" (Running ID: "abc-123") - { "actions": [{ "type": "DeleteHabit", "habitId": "abc-123" }], "aiMessage": "Deleted Running!" } - - SuggestBreakdown -- "Help me get fit" - { "actions": [{ "type": "SuggestBreakdown", "title": "Get Fit", "frequencyUnit": "Day", "frequencyQuantity": 1, "dueDate": "2026-02-08", "suggestedSubHabits": [{ "type": "CreateHabit", "title": "Morning Run", "description": "30min jog", "frequencyUnit": "Day", "frequencyQuantity": 1, "dueDate": "2026-02-08" }, { "type": "CreateHabit", "title": "Stretching", "frequencyUnit": "Day", "frequencyQuantity": 1, "dueDate": "2026-02-08" }] }], "aiMessage": "Here's a plan to get fit! Review and let me know what you think." } - - AssignTags -- "Tag my running habit as health" (Running ID: "abc-123") - { "actions": [{ "type": "AssignTags", "habitId": "abc-123", "tagNames": ["health"] }], "aiMessage": "Tagged Running as health!" } - - CreateSubHabit -- "Add stretching under my Workout Plan" (Workout Plan ID: "abc-123") - { "actions": [{ "type": "CreateSubHabit", "habitId": "abc-123", "title": "Stretching", "description": "Post-workout stretching routine" }], "aiMessage": "Added Stretching as a sub-habit under Workout Plan!" } - - Multi-action -- "I ran and meditated today" (Running ID: "a1", Meditation ID: "b2") - { "actions": [{ "type": "LogHabit", "habitId": "a1" }, { "type": "LogHabit", "habitId": "b2" }], "aiMessage": "Logged both!" } - - Bulk confirm pattern -- "Reschedule everything to Monday" (5 habits due today) - { "actions": [], "aiMessage": "I'd move these 5 habits to Monday:\n- Running\n- Meditation\n- Gym\n- Reading\n- Stretching\n\nWant me to go ahead?" } - - Conversational -- "I need help organizing my routine" - { "actions": [], "aiMessage": "Tell me about your day -- when do you wake up and what are the main things you need to get done?" } - - Out-of-scope -- "What's the capital of France?" - { "actions": [], "aiMessage": "That's outside what I can help with! Need help with any habits?" } - - Duplicate prevention -- "Create a running habit" (Running habit ALREADY EXISTS with ID "abc-123") - { "actions": [], "aiMessage": "You already have a Running habit! Did you mean to log it instead, or do you want a different one?" } - - CRITICAL: For LogHabit/UpdateHabit/DeleteHabit/CreateSubHabit, use EXACT IDs from Active Habits list. NEVER fabricate IDs. - - ### Action Types & Required Fields: - - CreateHabit: type, title, dueDate (YYYY-MM-DD, REQUIRED), dueTime (optional - HH:mm 24h format, e.g. "15:00" for 3pm, ONLY include when user mentions a specific time), frequencyUnit (Day | Week | Month | Year - OMIT for one-time tasks), frequencyQuantity (integer - OMIT for one-time tasks), description (optional), days (optional - only when frequencyQuantity is 1), isBadHabit (optional, true for habits to avoid/stop), slipAlertEnabled (optional, defaults to true when isBadHabit is true -- sends AI-generated motivational alerts before predicted slip windows), reminderEnabled (optional boolean - set true when user asks for a reminder/notification), reminderMinutesBefore (optional integer - minutes before dueTime to send reminder, default 15), tagNames (optional - array of tag name strings, ONLY when user explicitly asks to tag it), subHabits (optional - 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.), checklistItems (optional - 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) - LogHabit: type, habitId, note (optional - include if user shares context/feelings) - SuggestBreakdown: type, title (parent habit name), description (optional), frequencyUnit, frequencyQuantity, dueDate, suggestedSubHabits (array of habit objects with type: "CreateHabit", title, description, frequencyUnit, frequencyQuantity, dueDate) - UpdateHabit: type, habitId (REQUIRED - ID of existing habit), title (optional - new title), description (optional), frequencyUnit (optional), frequencyQuantity (optional), days (optional), isBadHabit (optional), dueDate (optional - new due date YYYY-MM-DD), dueTime (optional - HH:mm 24h format to set or change time), reminderEnabled (optional boolean), reminderMinutesBefore (optional integer). Only include fields that are changing. - DeleteHabit: type, habitId (REQUIRED - ID of existing habit to delete) - AssignTags: type, habitId (REQUIRED - ID of existing habit), tagNames (REQUIRED - array of tag name strings. Empty array [] removes all tags) - CreateSubHabit: type, habitId (REQUIRED - ID of existing PARENT habit from Active Habits list), title (REQUIRED), description (optional). Sub-habit inherits parent's frequency and due date. - - **When to use SuggestBreakdown:** - - 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. - - **When to use CreateHabit:** - - 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 - - **When to use UpdateHabit:** - - 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. - - **When to use DeleteHabit:** - - 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 - - **When to use AssignTags:** - - User says "tag my running habit as health" -> AssignTags with habitId and tagNames: ["health"] - - User says "add health and fitness tags to my gym habit" -> AssignTags with tagNames: ["health", "fitness"] - - User says "remove all tags from my reading habit" -> AssignTags with tagNames: [] - - User says "create a daily run habit tagged as health" -> CreateHabit with tagNames: ["health"] - - NEVER assign tags unless the user explicitly asks. Creating or logging habits without tag mention = no tagNames field - - **When to use CreateSubHabit:** - - 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 - - ### Conversational Examples (no actions needed): - - User: "How am I doing this week?" - { - "actions": [], - "aiMessage": "Looking at your stats: your Meditation habit has a 5-day streak going strong! Running is at 70% weekly completion. However, Reading has dropped - you haven't logged it in 4 days. Want to adjust the frequency or set a reminder approach?" - } - - User: "I'm sick today, can you reschedule everything?" - { - "actions": [ - { "type": "UpdateHabit", "habitId": "a1", "dueDate": "2026-03-20" }, - { "type": "UpdateHabit", "habitId": "b2", "dueDate": "2026-03-20" }, - { "type": "UpdateHabit", "habitId": "c3", "dueDate": "2026-03-20" } - ], - "aiMessage": "Rest up! I've moved all 3 of today's habits to tomorrow. Take care of yourself first." - } - - User: "How do I create a habit in the app?" - { - "actions": [], - "aiMessage": "You can create a habit two ways: tap the purple + button at the bottom center of the screen and fill in the details, or just tell me here! For example, say 'I want to meditate daily' and I'll create it for you." - } - - User: "I want to run a marathon in 6 months" - { - "actions": [ - { - "type": "SuggestBreakdown", - "title": "Marathon Training Plan", - "frequencyUnit": "Day", - "frequencyQuantity": 1, - "dueDate": "2026-03-19", - "suggestedSubHabits": [ - { "type": "CreateHabit", "title": "Easy Run (3-5km)", "description": "Month 1-2: Build base endurance", "frequencyUnit": "Week", "frequencyQuantity": 1, "days": ["Tuesday", "Thursday", "Saturday"], "dueDate": "2026-03-19" }, - { "type": "CreateHabit", "title": "Long Run (8-15km)", "description": "Weekly long run, increase distance gradually", "frequencyUnit": "Week", "frequencyQuantity": 1, "days": ["Sunday"], "dueDate": "2026-03-23" }, - { "type": "CreateHabit", "title": "Stretching & Recovery", "description": "Post-run stretching routine", "frequencyUnit": "Day", "frequencyQuantity": 1, "dueDate": "2026-03-19" } - ] - } - ], - "aiMessage": "6 months is a great timeline! Here's a progressive plan to get you marathon-ready. We'll start with shorter runs and build up. I'd suggest reviewing and adjusting the distances each month as you progress." - } - - User: "What habits go well with meditation?" - { - "actions": [], - "aiMessage": "Great habits to chain with meditation: journaling right after (captures insights while mind is clear), deep breathing before (eases the transition), and gratitude practice (pairs naturally with mindfulness). Want me to create any of these?" - } - - ### Frequency Examples: - - Daily = frequencyUnit: "Day", frequencyQuantity: 1 - - Weekly = frequencyUnit: "Week", frequencyQuantity: 1 - - Every 2 weeks = frequencyUnit: "Week", frequencyQuantity: 2 - - Monthly = frequencyUnit: "Month", frequencyQuantity: 1 - - Every 3 months = frequencyUnit: "Month", frequencyQuantity: 3 - - Yearly = frequencyUnit: "Year", frequencyQuantity: 1 - CRITICAL: ONLY include fields relevant to each action. No null/undefined values! - ALWAYS include "aiMessage" - NEVER leave it empty or null! - """); + var context = new PromptContext(activeHabits, userFacts, hasImage, routinePatterns, userTags, userToday, habitMetrics); + return _instance.Build(context); } }