diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index da881902..4a85ac21 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -191,6 +191,7 @@ public async Task> Handle( AiActionType.AssignTags => await ExecuteAssignTagsAsync(action, request.UserId, cancellationToken), AiActionType.UpdateHabit => await ExecuteUpdateHabitAsync(action, request.UserId, cancellationToken), AiActionType.DeleteHabit => await ExecuteDeleteHabitAsync(action, request.UserId, cancellationToken), + AiActionType.CreateSubHabit => await ExecuteCreateSubHabitAsync(action, request.UserId, cancellationToken), _ => Result.Failure<(Guid? Id, string? Name)>($"Unknown action type: {action.Type}") }; @@ -513,6 +514,27 @@ public async Task> Handle( return Result.Success<(Guid? Id, string? Name)>((habit.Id, title)); } + private async Task> ExecuteCreateSubHabitAsync( + AiAction action, Guid userId, CancellationToken ct) + { + if (action.HabitId is null) + return Result.Failure<(Guid? Id, string? Name)>("Parent habit ID is required for creating a sub-habit."); + + if (string.IsNullOrWhiteSpace(action.Title)) + return Result.Failure<(Guid? Id, string? Name)>("Title is required to create a sub-habit."); + + var result = await mediator.Send(new Orbit.Application.Habits.Commands.CreateSubHabitCommand( + userId, + action.HabitId.Value, + action.Title, + action.Description), ct); + + if (result.IsFailure) + return Result.Failure<(Guid? Id, string? Name)>(result.Error); + + return Result.Success<(Guid? Id, string? Name)>((result.Value, action.Title)); + } + private async Task AssignTagsToHabitAsync(Habit habit, List? tagNames, Guid userId, CancellationToken ct) { if (tagNames is not { Count: > 0 }) return; diff --git a/src/Orbit.Domain/Enums/AiActionType.cs b/src/Orbit.Domain/Enums/AiActionType.cs index ade70a13..1ddce8c5 100644 --- a/src/Orbit.Domain/Enums/AiActionType.cs +++ b/src/Orbit.Domain/Enums/AiActionType.cs @@ -7,5 +7,6 @@ public enum AiActionType SuggestBreakdown, AssignTags, UpdateHabit, - DeleteHabit + DeleteHabit, + CreateSubHabit } diff --git a/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs b/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs index 9fddde4b..5593e5fd 100644 --- a/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs +++ b/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs @@ -7,6 +7,15 @@ namespace Orbit.Infrastructure.Services; public static class SystemPromptBuilder { + private record PromptContext( + IReadOnlyList ActiveHabits, + IReadOnlyList UserFacts, + bool HasImage, + IReadOnlyList? RoutinePatterns, + IReadOnlyList? UserTags, + DateOnly? UserToday, + IReadOnlyDictionary? HabitMetrics); + public static string BuildSystemPrompt( IReadOnlyList activeHabits, IReadOnlyList userFacts, @@ -16,8 +25,24 @@ public static string BuildSystemPrompt( DateOnly? userToday = null, IReadOnlyDictionary? habitMetrics = null) { + 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 @@ -33,6 +58,7 @@ You are a SPECIALIZED assistant that helps users build better habits and organiz - **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 @@ -154,18 +180,22 @@ 31. AssignTags action requires habitId and tagNames array. An empty tagNames arr 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. """); + } + private static void AppendActiveHabits(StringBuilder sb, PromptContext ctx) + { sb.AppendLine(); sb.AppendLine("## User's Active Habits"); - if (activeHabits.Count == 0) + if (ctx.ActiveHabits.Count == 0) { sb.AppendLine("(none)"); } else { - foreach (var habit in activeHabits) + foreach (var habit in ctx.ActiveHabits) { var freqLabel = habit.FrequencyUnit is null ? "One-time" @@ -179,7 +209,7 @@ 31. AssignTags action requires habitId and tagNames array. An empty tagNames arr var tagsLabel = habit.Tags.Count > 0 ? $" | Tags: [{string.Join(", ", habit.Tags.Select(t => t.Name))}]" : ""; var metricsLabel = ""; - if (habitMetrics != null && habitMetrics.TryGetValue(habit.Id, out var metrics)) + 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"); @@ -207,13 +237,16 @@ 31. AssignTags action requires habitId and tagNames array. An empty tagNames arr 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"); } + } + private static void AppendUserTags(StringBuilder sb, PromptContext ctx) + { sb.AppendLine(); sb.AppendLine("## User's Tags"); - if (userTags is { Count: > 0 }) + if (ctx.UserTags is { Count: > 0 }) { - foreach (var tag in userTags) + foreach (var tag in ctx.UserTags) { sb.AppendLine($"- \"{tag.Name}\" (color: {tag.Color})"); } @@ -222,17 +255,20 @@ 31. AssignTags action requires habitId and tagNames array. An empty tagNames arr { sb.AppendLine("(no tags created yet)"); } + } + private static void AppendUserFacts(StringBuilder sb, PromptContext ctx) + { sb.AppendLine(); sb.AppendLine("## What You Know About This User"); - if (userFacts.Count == 0) + if (ctx.UserFacts.Count == 0) { sb.AppendLine("(nothing yet - learn as you go)"); } else { - var grouped = userFacts + var grouped = ctx.UserFacts .OrderByDescending(f => f.ExtractedAtUtc) .GroupBy(f => f.Category?.ToLowerInvariant() ?? "general") .ToDictionary(g => g.Key, g => g.ToList()); @@ -274,17 +310,20 @@ 31. AssignTags action requires habitId and tagNames array. An empty tagNames arr - 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) + { sb.AppendLine(); sb.AppendLine("## Your Understanding of This User's Routine"); - if (routinePatterns is null || routinePatterns.Count == 0) + if (ctx.RoutinePatterns is null || ctx.RoutinePatterns.Count == 0) { sb.AppendLine("(no routine patterns detected yet)"); } else { - foreach (var pattern in routinePatterns) + foreach (var pattern in ctx.RoutinePatterns) { sb.AppendLine($"- \"{pattern.HabitTitle}\": {pattern.Description} (confidence: {pattern.Confidence}, consistency: {pattern.ConsistencyScore:P0})"); } @@ -294,108 +333,119 @@ 31. AssignTags action requires habitId and tagNames array. An empty tagNames arr 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: {(userToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}"); + sb.AppendLine($"## Today's Date: {(ctx.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}"); sb.AppendLine(); + } - if (hasImage) - { - 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") + 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": [ { - "aiMessage": "Created 5 habit groups with all their sub-habits from your screenshot!", - "actions": [ - { - "type": "CreateHabit", - "title": "Water", - "frequencyUnit": "Day", - "frequencyQuantity": 1, - "dueDate": "{{(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": "{{(userToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}}", - "subHabits": [ - { "title": "Dinner" } - ] - }, - { - "type": "CreateHabit", - "title": "Waking up", - "frequencyUnit": "Day", - "frequencyQuantity": 1, - "dueDate": "{{(userToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}}" - }, - { - "type": "CreateHabit", - "title": "Post Lunch", - "frequencyUnit": "Day", - "frequencyQuantity": 1, - "dueDate": "{{(userToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}}", - "subHabits": [ - { "title": "Creatine" }, - { "title": "Multivitamin (2)" } - ] - }, - { - "type": "CreateHabit", - "title": "Anytime", - "frequencyUnit": "Day", - "frequencyQuantity": 1, - "dueDate": "{{(userToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}}", - "subHabits": [ - { "title": "Self care" }, - { "title": "Exercise" }, - { "title": "Cardio" } - ] - } + "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: {activeHabits.Count} active habits"); + 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): @@ -433,6 +483,9 @@ CreateHabit with subHabits -- "Create workout plan with gym MWF and cardio TuTh" 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!" } @@ -448,7 +501,7 @@ CreateHabit with subHabits -- "Create workout plan with gym MWF and cardio TuTh" 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, use EXACT IDs from Active Habits list. NEVER fabricate IDs. + CRITICAL: For LogHabit/UpdateHabit/DeleteHabit/CreateSubHabit, use EXACT IDs from Active Habits list. NEVER fabricate IDs. ### Action Types & Required Fields: @@ -458,6 +511,7 @@ CreateHabit with subHabits -- "Create workout plan with gym MWF and cardio TuTh" 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). 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 @@ -494,6 +548,12 @@ CreateHabit with subHabits -- "Create workout plan with gym MWF and cardio TuTh" - 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?" @@ -554,7 +614,5 @@ CreateHabit with subHabits -- "Create workout plan with gym MWF and cardio TuTh" CRITICAL: ONLY include fields relevant to each action. No null/undefined values! ALWAYS include "aiMessage" - NEVER leave it empty or null! """); - - return sb.ToString(); } }