diff --git a/src/TimesheetGPT.Core/ConfigureServices.cs b/src/TimesheetGPT.Core/ConfigureServices.cs index f44ddb2..e9ff31b 100644 --- a/src/TimesheetGPT.Core/ConfigureServices.cs +++ b/src/TimesheetGPT.Core/ConfigureServices.cs @@ -9,7 +9,7 @@ public static class ConfigureServices public static IServiceCollection AddApplication(this IServiceCollection services) { services.AddScoped(); - // services.AddScoped(); //TODO: Try langchain out + services.AddScoped(); return services; } diff --git a/src/TimesheetGPT.Core/Interfaces/IAiService.cs b/src/TimesheetGPT.Core/Interfaces/IAiService.cs index 3f83919..2b5d941 100644 --- a/src/TimesheetGPT.Core/Interfaces/IAiService.cs +++ b/src/TimesheetGPT.Core/Interfaces/IAiService.cs @@ -1,6 +1,9 @@ +using TimesheetGPT.Core.Models; + namespace TimesheetGPT.Core.Interfaces; public interface IAiService { - public Task GetSummary(string text, string extraPrompts, string additionalNotes); + public Task ChatWithGraphApi(string ask); + public Task GetSummaryBoring(IList emails, IEnumerable meetings, string extraPrompts, CancellationToken cancellationToken, string additionalNotes = ""); } diff --git a/src/TimesheetGPT.Core/Interfaces/IGraphService.cs b/src/TimesheetGPT.Core/Interfaces/IGraphService.cs index 7808583..7e68073 100644 --- a/src/TimesheetGPT.Core/Interfaces/IGraphService.cs +++ b/src/TimesheetGPT.Core/Interfaces/IGraphService.cs @@ -5,7 +5,8 @@ namespace TimesheetGPT.Core.Interfaces; public interface IGraphService { - public Task> GetEmailSubjects(DateTime date); - public Task> GetMeetings(DateTime date); + public Task> GetSentEmails(DateTime date, CancellationToken cancellationToken); + public Task> GetMeetings(DateTime date, CancellationToken cancellationToken); public Task> GetTeamsCalls(DateTime date); + Task GetEmailBody(string subject, CancellationToken ct); } diff --git a/src/TimesheetGPT.Core/Models/Email.cs b/src/TimesheetGPT.Core/Models/Email.cs new file mode 100644 index 0000000..76048dc --- /dev/null +++ b/src/TimesheetGPT.Core/Models/Email.cs @@ -0,0 +1,9 @@ +namespace TimesheetGPT.Core.Models; + +public class Email +{ + public string? Subject { get; set; } + public string? Body { get; set; } + public string? To { get; set; } + public string? Id { get; set; } +} diff --git a/src/TimesheetGPT.Core/Models/Summary.cs b/src/TimesheetGPT.Core/Models/Summary.cs index be1b48d..e342c5f 100644 --- a/src/TimesheetGPT.Core/Models/Summary.cs +++ b/src/TimesheetGPT.Core/Models/Summary.cs @@ -1,9 +1,9 @@ namespace TimesheetGPT.Core.Models; -public class SummaryWithRaw +public class Summary { - public List Emails { get; set; } - public List Meetings { get; set; } - public string Summary { get; set; } - public string ModelUsed { get; set; } + public List Emails { get; set; } = []; + public List Meetings { get; set; } = []; + public string? Text { get; set; } + public string? ModelUsed { get; set; } } diff --git a/src/TimesheetGPT.Core/Plugins.cs b/src/TimesheetGPT.Core/Plugins.cs new file mode 100644 index 0000000..2e59baa --- /dev/null +++ b/src/TimesheetGPT.Core/Plugins.cs @@ -0,0 +1,39 @@ +using Microsoft.SemanticKernel; +using System.ComponentModel; +using System.Globalization; +using TimesheetGPT.Core.Interfaces; +using System.Text.Json; + + +namespace TimesheetGPT.Core; + +public class GraphPlugins(IGraphService graphService) +{ + [SKFunction, Description("Get email body from Id")] + public async Task GetEmailBody(string id) + { + return (await graphService.GetEmailBody(id, new CancellationToken())).Body; + } + + [SKFunction, Description("Get sent emails (subject, to, Id) for a date)")] + public async Task GetSentEmails(DateTime dateTime) + { + var emails = await graphService.GetSentEmails(dateTime, new CancellationToken()); + return JsonSerializer.Serialize(emails); + } + + [SKFunction, Description("Get meetings for a date")] + public async Task GetMeetings(DateTime dateTime) + { + var meetings = await graphService.GetMeetings(dateTime, new CancellationToken()); + return JsonSerializer.Serialize(meetings); + } + + [SKFunction, Description("Get todays date")] + public string GetTodaysDate(DateTime dateTime) + { + //TODO: Use browser datetime + return DateTime.Today.ToString(CultureInfo.InvariantCulture); + + } +} diff --git a/src/TimesheetGPT.Core/PromptConfigs.cs b/src/TimesheetGPT.Core/PromptConfigs.cs new file mode 100644 index 0000000..a2d6f65 --- /dev/null +++ b/src/TimesheetGPT.Core/PromptConfigs.cs @@ -0,0 +1,86 @@ +using Microsoft.SemanticKernel.AI; +using Microsoft.SemanticKernel.Connectors.AI.OpenAI; +using Microsoft.SemanticKernel.TemplateEngine; + +namespace TimesheetGPT.Core; + +// TODO: Refactor into a JSON file +// https://learn.microsoft.com/en-us/semantic-kernel/ai-orchestration/plugins/semantic-functions/serializing-semantic-functions +public static class PromptConfigs +{ + public static readonly PromptTemplateConfig SummarizeEmailsAndCalendar = new() + { + Schema = 1, + Description = "Summarises users emails and meetings.", + ModelSettings = new List + { + // Note: Token limit hurts things like additional notes. If you don't have enough, the prompt will suck + new OpenAIRequestSettings { MaxTokens = 1000, Temperature = 0, TopP = 0.5 } + }, + Input = + { + Parameters = new List + { + new() + { + Name = PromptVariables.Meetings, + Description = "meetings", + DefaultValue = "" + }, + new() + { + Name = PromptVariables.Emails, + Description = "emails", + DefaultValue = "" + }, + new() + { + Name = PromptVariables.AdditionalNotes, + Description = "Additional Notes", + DefaultValue = "" + }, + new() + { + Name = PromptVariables.ExtraPrompts, + Description = "extraPrompts", + DefaultValue = "" + } + } + } + }; + + public static readonly PromptTemplateConfig SummarizeEmailBody = new() + { + Schema = 1, + Description = "Summarizes body of an email", + ModelSettings = new List + { + // Note: Token limit hurts things like additional notes. If you don't have enough, the prompt will suck + new OpenAIRequestSettings { MaxTokens = 1000, Temperature = 0, TopP = 0.5 } + }, + Input = + { + Parameters = new List + { + new() + { + Name = PromptVariables.Recipients, + Description = nameof(PromptVariables.Recipients), + DefaultValue = "" + }, + new() + { + Name = PromptVariables.Subject, + Description = nameof(PromptVariables.Subject), + DefaultValue = "" + }, + new() + { + Name = PromptVariables.EmailBody, + Description = nameof(PromptVariables.EmailBody), + DefaultValue = "" + } + } + } + }; +} diff --git a/src/TimesheetGPT.Core/PromptTemplates.cs b/src/TimesheetGPT.Core/PromptTemplates.cs new file mode 100644 index 0000000..4d7dc37 --- /dev/null +++ b/src/TimesheetGPT.Core/PromptTemplates.cs @@ -0,0 +1,44 @@ +namespace TimesheetGPT.Core; + +public static class PromptTemplates +{ + // Doesn't hot reload + public static readonly string SummarizeEmailsAndCalendar = $""" + Generate a concise timesheet summary in chronological order from my meetings and emails. + + For meetings, follow the format 'Meeting Name - Meeting Length' + Skip non-essential meetings like Daily Scrums. + Treat all-day (or 9-hour) meetings as bookings e.g. Brady was booked as the Bench Master. + Use email subjects to figure out what tasks were completed. + Note that emails starting with 'RE:' are replies, not new tasks. + An email titled 'Sprint X Review' means I led that Sprint review/retro. + Merge meetings and emails into one summary. If an item appears in both, mention it just once. + Ignore the day's meetings if an email is marked 'Sick Today.' + Appointments labeled 'Leave' should be omitted. + Only output the timesheet summary so i can copy it directly. Use a Markdown unordered list, keeping it lighthearted with a few emojis. 🌟 + + {PromptVarFormatter(PromptVariables.ExtraPrompts)} + + Here is the data: + + {PromptVarFormatter(PromptVariables.Emails)} + + {PromptVarFormatter(PromptVariables.Meetings)} + + Additional notes: + + {PromptVarFormatter(PromptVariables.AdditionalNotes)} + """; + + public static readonly string SummarizeEmailBody = $""" + Summarise this email body in 1-2 sentences. This summary will later be used to generate a timesheet summary. + Respond in this format: Recipients - Subject - Summary of body + + Here is the data: + Recipients: {PromptVarFormatter(PromptVariables.Recipients)} + Subject: {PromptVarFormatter(PromptVariables.Subject)} + Body: {PromptVarFormatter(PromptVariables.Subject)} + """; + + private static string PromptVarFormatter(string promptVar) => "{{$" + promptVar + "}}"; +} diff --git a/src/TimesheetGPT.Core/PromptVariables.cs b/src/TimesheetGPT.Core/PromptVariables.cs new file mode 100644 index 0000000..a207630 --- /dev/null +++ b/src/TimesheetGPT.Core/PromptVariables.cs @@ -0,0 +1,13 @@ +namespace TimesheetGPT.Core; + +public static class PromptVariables +{ + public const string Emails = "emails"; + public const string Meetings = "meetings"; + public const string ExtraPrompts = "extraPrompts"; + public const string AdditionalNotes = "additionalNotes"; + + public const string Recipients = "recipients"; + public const string Subject = "subject"; + public const string EmailBody = "emailBody"; +} \ No newline at end of file diff --git a/src/TimesheetGPT.Core/Prompts.cs b/src/TimesheetGPT.Core/Prompts.cs deleted file mode 100644 index f952dcb..0000000 --- a/src/TimesheetGPT.Core/Prompts.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace TimesheetGPT.Core; - -public static class Prompts -{ - - - // Doesn't hot reload - public static readonly string SummarizeEmailsAndCalendar = $$$""" - These are all my meetings attended and emails sent for the day, turn them into a succinct summary for a timesheet. - Meetings are in the format of 'Meeting Name - Meeting Length', use the length to determine relevancy and importance but dont include it in the output - Ignore meetings that are trivial e.g. Daily Scrums, these happen every day and are not important - all day (or 9 hour) meetings are bookings e.g. which Client I worked for or if it contains 'SSW' this means its an internal day and what comes after 'SSW' was my focus - Use the emails to determine tasks complete. - Note if the email subject starts with RE:, this means its a reply not the original - If you see an email with 'Sprint X Review', this means I ran the Sprint review/retro meeting - - Combine the emails and meetings into a summary for my timesheet. - Sometimes I run a sprint review/retro meeting and send an email at the end. This means the email and meeting are the same thing, so only include it once - - if there is an email with 'Sick Today', this means I was sick and didnt work so ignore the meetings in my calendar for that day - - keep response lighthearted and use a few emojis, but dont overdo it - - Only output a Markdown unordered list. - If there is no meetings or emails, respond with a joke about the user not doing any work on this day :) - - {{${{{PromptVariables.Input}}}}} - - {{${{{PromptVariables.ExtraPrompts}}}}} - - {{${{{PromptVariables.AdditionalNotes}}}}} - - """; - -} - - -public static class PromptVariables -{ - public const string ExtraPrompts = "extraPrompts"; - public const string AdditionalNotes = "additionalNotes"; - public const string Input = "inputContent"; -} \ No newline at end of file diff --git a/src/TimesheetGPT.Core/Services/GraphService.cs b/src/TimesheetGPT.Core/Services/GraphService.cs index 386e945..b32145d 100644 --- a/src/TimesheetGPT.Core/Services/GraphService.cs +++ b/src/TimesheetGPT.Core/Services/GraphService.cs @@ -18,7 +18,7 @@ public GraphService(GraphServiceClient client) } - public async Task> GetEmailSubjects(DateTime date) + public async Task> GetSentEmails(DateTime date, CancellationToken cancellationToken) { var nextDay = date.AddDays(1); var dateUtc = date.ToUniversalTime(); @@ -29,24 +29,29 @@ public async Task> GetEmailSubjects(DateTime date) { rc.QueryParameters.Top = 999; rc.QueryParameters.Select = - new[] { "subject" }; + new[] { "subject", "bodyPreview", "toRecipients", "id" }; rc.QueryParameters.Filter = $"sentDateTime ge {dateUtc:yyyy-MM-ddTHH:mm:ssZ} and sentDateTime lt {nextDayUtc:yyyy-MM-ddTHH:mm:ssZ}"; rc.QueryParameters.Orderby = new[] { "sentDateTime asc" }; - }); - + }, cancellationToken); if (messages is { Value.Count: > 1 }) { - return messages.Value.Select(m => m.Subject).ToList(); + return new List(messages.Value.Select(m => new Email + { + Subject = m.Subject, + Body = m.BodyPreview ?? "", + To = string.Join(", ", m.ToRecipients?.Select(r => r.EmailAddress?.Name).ToList() ?? new List()), + Id = m.Id + })); } - return new List(); //slack + return new List(); //slack } - public async Task> GetMeetings(DateTime date) + public async Task> GetMeetings(DateTime date, CancellationToken cancellationToken) { var nextDay = date.AddDays(1); var dateUtc = date.ToUniversalTime(); @@ -59,14 +64,14 @@ public async Task> GetMeetings(DateTime date) rc.QueryParameters.EndDateTime = nextDayUtc.ToString("o"); rc.QueryParameters.Orderby = new[] { "start/dateTime" }; rc.QueryParameters.Select = new[] { "subject", "start", "end", "occurrenceId" }; - }); + }, cancellationToken); if (meetings is { Value.Count: > 1 }) { return meetings.Value.Select(m => new Meeting { - Name = m.Subject, - Length = DateTime.Parse(m.End.DateTime) - DateTime.Parse(m.Start.DateTime), + Name = m.Subject ?? "", + Length = DateTime.Parse(m.End?.DateTime ?? string.Empty) - DateTime.Parse(m.Start?.DateTime ?? string.Empty), Repeating = m.Type == EventType.Occurrence, // Sender = m.EmailAddress.Address TODO: Why is Organizer and attendees null? permissions? }).ToList(); @@ -94,7 +99,7 @@ public async Task> GetTeamsCalls(DateTime date) { return calls.Value.Select(m => new TeamsCall { - Attendees = m.Participants.Select(p => p.User.DisplayName).ToList(), + Attendees = m.Participants?.Select(p => p.User?.DisplayName).ToList() ?? new List(), Length = m.EndDateTime - m.StartDateTime ?? TimeSpan.Zero, }).ToList(); } @@ -106,10 +111,31 @@ public async Task> GetTeamsCalls(DateTime date) return new List(); } + + public async Task GetEmailBody(string id, CancellationToken ct) + { + var message = await _client.Me.Messages[id] + .GetAsync(rc => + { + rc.QueryParameters.Select = + new[] { "bodyPreview", "toRecipients" }; + }, ct); + + if (message != null) + { + return new Email + { + Body = message.BodyPreview, + To = string.Join(", ", (message.ToRecipients ?? new List()).Select(r => r.EmailAddress?.Name).ToList()) + }; + } + + return new Email(); //slack + } } public class TeamsCall { - public List Attendees { get; set; } + public List? Attendees { get; set; } public TimeSpan Length { get; set; } } diff --git a/src/TimesheetGPT.Core/Services/LangChainAiService.cs b/src/TimesheetGPT.Core/Services/LangChainAiService.cs deleted file mode 100644 index 96a308c..0000000 --- a/src/TimesheetGPT.Core/Services/LangChainAiService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using TimesheetGPT.Core.Interfaces; - -namespace TimesheetGPT.Core.Services; - -public class LangChainAiService : IAiService -{ - public async Task GetSummary(string text, string extraPrompts, string additionalNotes) - { - throw new NotImplementedException(); - } -} diff --git a/src/TimesheetGPT.Core/Services/SemKerAiService.cs b/src/TimesheetGPT.Core/Services/SemKerAiService.cs index b48ea5d..c151d92 100644 --- a/src/TimesheetGPT.Core/Services/SemKerAiService.cs +++ b/src/TimesheetGPT.Core/Services/SemKerAiService.cs @@ -1,46 +1,95 @@ using Microsoft.Extensions.Configuration; using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Planners; +using System.Text; using TimesheetGPT.Core.Interfaces; +using TimesheetGPT.Core.Models; namespace TimesheetGPT.Core.Services; public class SemKerAiService : IAiService { private readonly string _apiKey; - public SemKerAiService(IConfiguration configuration) + private readonly IGraphService _graphService; + public SemKerAiService(IConfiguration configuration, IGraphService graphService) { + _graphService = graphService; ArgumentNullException.ThrowIfNull(configuration); _apiKey = configuration["OpenAI:ApiKey"] ?? ""; } - public async Task GetSummary(string text, string extraPrompts, string additionalNotes = "") + public async Task ChatWithGraphApi(string ask) { var builder = new KernelBuilder(); - // builder.WithAzureChatCompletionService( - // "gpt-4-turbo", // Azure OpenAI Deployment Name - // "https://contoso.openai.azure.com/", // Azure OpenAI Endpoint - // "...your Azure OpenAI Key..."); // Azure OpenAI Key - builder.WithOpenAIChatCompletionService( // "gpt-3.5-turbo", // Cheap mode - "gpt-4", // 💸 + // "gpt-4", // 💸 + "gpt-4-1106-preview", // ⏩ _apiKey); var kernel = builder.Build(); - - // Note: Token limit hurts things like additional notes. If you don't have enough, the prompt will suck - var summarizeFunction = kernel.CreateSemanticFunction(Prompts.SummarizeEmailsAndCalendar, maxTokens: 400, temperature: 0, topP: 0.5); - + + kernel.ImportFunctions(new GraphPlugins(_graphService)); + + var planner = new StepwisePlanner(kernel); + + var plan = planner.CreatePlan(ask); + var context = kernel.CreateNewContext(); + var res = await plan.InvokeAsync(context); + + return res.GetValue(); + } + + public async Task GetSummaryBoring(IList emails, IEnumerable meetings, string extraPrompts, CancellationToken cancellationToken, string additionalNotes = "") + { + var builder = new KernelBuilder(); + + builder.WithOpenAIChatCompletionService( + "gpt-4-1106-preview", // ⏩ + _apiKey); + + var kernel = builder.Build(); - context.Variables.TryAdd(PromptVariables.Input, text); + var generateTimesheetFunction = kernel.CreateSemanticFunction(PromptTemplates.SummarizeEmailsAndCalendar, PromptConfigs.SummarizeEmailsAndCalendar); + + var context = kernel.CreateNewContext(); + + context.Variables.TryAdd(PromptVariables.Emails, StringifyEmails(emails)); + context.Variables.TryAdd(PromptVariables.Meetings, StringifyMeetings(meetings)); context.Variables.TryAdd(PromptVariables.AdditionalNotes, additionalNotes); context.Variables.TryAdd(PromptVariables.ExtraPrompts, extraPrompts); - var summary = await summarizeFunction.InvokeAsync(context); + await generateTimesheetFunction.InvokeAsync(context, cancellationToken: cancellationToken); + + return context.Result; + } + + private static string StringifyMeetings(IEnumerable meetings) + { + var sb = new StringBuilder(); + sb.AppendLine("Calendar Events (name - length)"); + + foreach (var meeting in meetings) + { + sb.AppendLine($"{meeting.Name} - {meeting.Length}"); + } + + return sb.ToString(); + } + + private static string StringifyEmails(IEnumerable emails) + { + var sb = new StringBuilder(); + sb.AppendLine("Sent emails (recipients - subject - bodyPreview)"); + + foreach (var email in emails) + { + sb.AppendLine($"{email.To} - {email.Subject}"); + } - return summary.Result; + return sb.ToString(); } } diff --git a/src/TimesheetGPT.Core/Services/TimesheetService.cs b/src/TimesheetGPT.Core/Services/TimesheetService.cs index b77ef77..55b577f 100644 --- a/src/TimesheetGPT.Core/Services/TimesheetService.cs +++ b/src/TimesheetGPT.Core/Services/TimesheetService.cs @@ -1,4 +1,3 @@ -using System.Collections; using Microsoft.Graph; using TimesheetGPT.Core.Interfaces; using TimesheetGPT.Core.Models; @@ -7,40 +6,28 @@ namespace TimesheetGPT.Core.Services; public class TimesheetService(IAiService aiService, GraphServiceClient graphServiceClient) { - public async Task GenerateSummary(DateTime date, string extraPrompts = "", string additionalNotes = "") + public async Task GenerateSummary(DateTime date, string extraPrompts = "", string additionalNotes = "", CancellationToken ct = default) { var graphService = new GraphService(graphServiceClient); - var emailSubjects = await graphService.GetEmailSubjects(date); - var meetings = await graphService.GetMeetings(date); + var emails = await graphService.GetSentEmails(date, ct); + var meetings = await graphService.GetMeetings(date, ct); // var calls = await graphService.GetTeamsCalls(date); // TODO: SSW needs to allow the CallRecords.Read.All scope for this to work - var summary = await aiService.GetSummary(StringifyData(emailSubjects, meetings), extraPrompts, additionalNotes); + var summary = await aiService.GetSummaryBoring(emails, meetings, extraPrompts, ct, additionalNotes); - return new SummaryWithRaw + return new Summary { - Emails = emailSubjects, + Emails = emails, Meetings = meetings, - Summary = summary, + Text = summary, ModelUsed = "GPT-4" //TODO: get this from somewhere }; } - - private string StringifyData(IEnumerable emails, IList meetings) + + public async Task ChatWithGraph(string ask) { - var result = "Sent emails (subject) \n"; - foreach (var email in emails) - { - result += email + "\n"; - } - result += "\n Calendar Events (name - length) \n"; - - foreach (var meeting in meetings) - { - result += $"{meeting.Name} - {meeting.Length} \n"; - } - - return result; + return await aiService.ChatWithGraphApi(ask); } } diff --git a/src/TimesheetGPT.Core/TimesheetGPT.Core.csproj b/src/TimesheetGPT.Core/TimesheetGPT.Core.csproj index 4a45367..1ced966 100644 --- a/src/TimesheetGPT.Core/TimesheetGPT.Core.csproj +++ b/src/TimesheetGPT.Core/TimesheetGPT.Core.csproj @@ -8,12 +8,13 @@ - - - - - - + + + + + + + diff --git a/src/TimesheetGPT.Gateway/Pages/Error.cshtml.cs b/src/TimesheetGPT.Gateway/Pages/Error.cshtml.cs index 4c7c361..5271d61 100644 --- a/src/TimesheetGPT.Gateway/Pages/Error.cshtml.cs +++ b/src/TimesheetGPT.Gateway/Pages/Error.cshtml.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.Logging; namespace TimesheetGPT.WebUI.Pages; diff --git a/src/TimesheetGPT.Gateway/Pages/_Host.cshtml b/src/TimesheetGPT.Gateway/Pages/_Host.cshtml index 2056ccd..2362634 100644 --- a/src/TimesheetGPT.Gateway/Pages/_Host.cshtml +++ b/src/TimesheetGPT.Gateway/Pages/_Host.cshtml @@ -1,6 +1,7 @@ @page "/" @using Microsoft.AspNetCore.Components.Web -@namespace TimesheetGPT.WebUI.Pages +@using TimesheetGPT.WebUI +@namespace TimesheetGPT.Gateway.Pages @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/TimesheetGPT.Gateway/Program.cs b/src/TimesheetGPT.Gateway/Program.cs index 25b2ade..ebedcdc 100644 --- a/src/TimesheetGPT.Gateway/Program.cs +++ b/src/TimesheetGPT.Gateway/Program.cs @@ -8,6 +8,8 @@ builder.Services.AddTimesheetGptUi(builder.Configuration); builder.Services.AddTimesheetGptApi(builder.Configuration); +builder.Services.AddApplicationInsightsTelemetry(); + var app = builder.Build(); app.UseTimesheetGptApi(); diff --git a/src/TimesheetGPT.Gateway/appsettings.json b/src/TimesheetGPT.Gateway/appsettings.json index 882a5c4..ca41598 100644 --- a/src/TimesheetGPT.Gateway/appsettings.json +++ b/src/TimesheetGPT.Gateway/appsettings.json @@ -20,6 +20,9 @@ } }, "AllowedHosts": "*", + "ApplicationInsights": { + "ConnectionString": "" + }, "OpenAi": { "ApiKey": "" } diff --git a/src/TimesheetGPT.WebAPI/DependencyInjection.cs b/src/TimesheetGPT.WebAPI/DependencyInjection.cs index 164c030..7682af5 100644 --- a/src/TimesheetGPT.WebAPI/DependencyInjection.cs +++ b/src/TimesheetGPT.WebAPI/DependencyInjection.cs @@ -25,7 +25,7 @@ public static WebApplication UseTimesheetGptApi(this WebApplication app) app.UseSwaggerUI(); } - app.MapGetSubjects(); + app.MapChat(); return app; } diff --git a/src/TimesheetGPT.WebAPI/Endpoints/EmailEndpoints.cs b/src/TimesheetGPT.WebAPI/Endpoints/EmailEndpoints.cs index 1ea0e7d..d61bf65 100644 --- a/src/TimesheetGPT.WebAPI/Endpoints/EmailEndpoints.cs +++ b/src/TimesheetGPT.WebAPI/Endpoints/EmailEndpoints.cs @@ -5,17 +5,10 @@ namespace TimesheetGPT.WebAPI.Endpoints; public static class EmailEndpoints { - public static void MapGetSubjects(this IEndpointRouteBuilder app) => - app.MapGet("/get-subjects", async () => + public static void MapChat(this IEndpointRouteBuilder app) => + app.MapGet("/api/chat", async (string ask) => { - throw new NotImplementedException(); - // Initialize GraphServiceClient - // var graphClient = new GraphServiceClient(); //TODO: Get a token from the request and use it to initialize the client - // Fetch user info - - // var result = await graphService.GetEmailSubjects(DateTime.Now - TimeSpan.FromDays(1)); - // return result; }) .WithName("GetEmailSubjects") .WithOpenApi(); diff --git a/src/TimesheetGPT.WebAPI/TimesheetGPT.WebAPI.csproj b/src/TimesheetGPT.WebAPI/TimesheetGPT.WebAPI.csproj index 3183891..d38b0d1 100644 --- a/src/TimesheetGPT.WebAPI/TimesheetGPT.WebAPI.csproj +++ b/src/TimesheetGPT.WebAPI/TimesheetGPT.WebAPI.csproj @@ -9,9 +9,9 @@ - - - + + + diff --git a/src/TimesheetGPT.WebUI/Components/Results.razor b/src/TimesheetGPT.WebUI/Components/Results.razor new file mode 100644 index 0000000..e25919d --- /dev/null +++ b/src/TimesheetGPT.WebUI/Components/Results.razor @@ -0,0 +1,81 @@ +@using TimesheetGPT.WebUI.Models + + @if (ViewModel.Loading) + { + + + + } + else + { + + + @if (!ViewModel.Loading) + { + @if (ViewModel.Emails is not null && ViewModel.Emails?.Count > 1) + { + + +
    + @foreach (var email in ViewModel.Emails.Where(m => !string.IsNullOrEmpty(m.Subject))) + { +
  • + @email.Subject +
  • + } +
+
+
+ } + } + + @if (!ViewModel.Loading && ViewModel.Meetings != null) + { + var meetingsMiddleBreak = "12"; + + if (ViewModel.Meetings is not null && ViewModel.Meetings?.Count > 1 && !ViewModel.Loading) + { + meetingsMiddleBreak = "6"; + } + + +
    + @foreach (var meeting in ViewModel.Meetings) + { + string formatted; + + var timeSpan = meeting.Length; + if (timeSpan.Hours > 0) + { + formatted = $"{timeSpan.Hours} hours {timeSpan.Minutes} minutes"; + } + else + { + formatted = $"{timeSpan.Minutes} minutes"; + } + + @meeting.Name + @formatted + } +
+
+
+ } + + @if (!ViewModel.Loading && ViewModel.SummaryText != null) + { + + + + + + + } +
+ } +
+ +@code { + [Parameter] + public ResultVm ViewModel { get; set; } = new (); +} \ No newline at end of file diff --git a/src/TimesheetGPT.WebUI/Models/ResultsVm.cs b/src/TimesheetGPT.WebUI/Models/ResultsVm.cs new file mode 100644 index 0000000..bc2828a --- /dev/null +++ b/src/TimesheetGPT.WebUI/Models/ResultsVm.cs @@ -0,0 +1,11 @@ +using TimesheetGPT.Core.Models; + +namespace TimesheetGPT.WebUI.Models; + +public class ResultVm +{ + public bool Loading { get; set; } + public IList? Emails { get; set; } + public IList? Meetings { get; set; } + public string? SummaryText { get; set; } +} \ No newline at end of file diff --git a/src/TimesheetGPT.WebUI/Pages/DateRangeTimesheets.razor b/src/TimesheetGPT.WebUI/Pages/DateRangeTimesheets.razor new file mode 100644 index 0000000..794b47a --- /dev/null +++ b/src/TimesheetGPT.WebUI/Pages/DateRangeTimesheets.razor @@ -0,0 +1,150 @@ +@page "/date-range" +@using TimesheetGPT.WebUI.Models + + +@inject GraphServiceClient GraphServiceClient +@inject MicrosoftIdentityConsentAndConditionalAccessHandler ConsentHandler +@inject TimesheetService TimesheetService + + + Generate Timesheet + + Hi @_name, what days would you like to generate a timesheet for? + + + + + + + + + + + + @if (_hasBeenGenerated) + { + Regenerate + } + else + { + Generate + } + + + + @if (_additionalNotesError) + { + Additional notes too long. You have @_additionalNotes.Length characters. We only accept 400 max. + } + + + + + + + + + +@foreach (var res in _results) +{ + +} + +@code { + bool _loading; + string? _name = "..."; + string? _extraPrompt; + string _additionalNotes = string.Empty; + bool _hasBeenGenerated; + + private readonly IList _results = new List(); + + private DateRange _dateRange = new( + DateTime.Now.Date.AddDays(-(int)DateTime.Now.DayOfWeek + (int)DayOfWeek.Monday), + DateTime.Now.Date.AddDays(-(int)DateTime.Now.DayOfWeek + (int)DayOfWeek.Friday) + ); + + + bool _additionalNotesError; + + protected async override Task OnInitializedAsync() + { + try + { + var user = await GraphServiceClient.Me.GetAsync(); + _name = user?.DisplayName; + } + catch (Exception ex) + { + ConsentHandler.HandleException(ex); + } + } + + private async Task GenerateTimesheet() + { + _loading = true; + try + { + _additionalNotesError = false; + _additionalNotesError = CheckAdditionalNotesLength(); + if (_additionalNotesError) + { + return; + } + + await Parallel.ForEachAsync(EachDay(_dateRange.Start ?? DateTime.Now, _dateRange.End ?? DateTime.Now), async (date, token) => + { + var result = new ResultVm + { + Loading = true + }; + + _results.Add(result); + + var summary = await TimesheetService.GenerateSummary(date, _extraPrompt ?? "", _additionalNotes, token); + + result.Meetings = summary.Meetings; + result.Emails = summary.Emails; + result.SummaryText = summary.Text; + result.Loading = false; + + _hasBeenGenerated = true; + }); + } + catch (Exception ex) + { + ConsentHandler.HandleException(ex); + } + finally + { + _loading = false; + } + + } + + private bool CheckAdditionalNotesLength() + { + return _additionalNotes.Length > 400; + } + + static IEnumerable EachDay(DateTime start, DateTime end) + { + for (var day = start; day <= end; day = day.AddDays(1)) + { + yield return day; + } + } +} \ No newline at end of file diff --git a/src/TimesheetGPT.WebUI/Pages/GraphChat.razor b/src/TimesheetGPT.WebUI/Pages/GraphChat.razor new file mode 100644 index 0000000..660d70e --- /dev/null +++ b/src/TimesheetGPT.WebUI/Pages/GraphChat.razor @@ -0,0 +1,99 @@ +@page "/chat" + +@inject GraphServiceClient GraphServiceClient +@inject MicrosoftIdentityConsentAndConditionalAccessHandler ConsentHandler +@inject TimesheetService TimesheetService + + + Chat with GraphAPI + + Hi @_name, ask questions about sent emails, meetings attended, and more. + + + + @foreach (var message in _messages) + { +
+ +
+ } +
+ + + Send + +
+
+ +@code { + + private class Message + { + public int MessageId { get; set; } + public string? MessageText { get; set; } + public Sender Sender { get; set; } + } + + private enum Sender + { + User, + Bot + } + + string? _name = "..."; + string? _userInput; + readonly IList _messages = new List(); + int _messageCount; + + protected async override Task OnInitializedAsync() + { + _messages.Add(new Message + { + MessageText = "Hello, I'm the bot. How can I help you?", + Sender = Sender.Bot + }); + try + { + var user = await GraphServiceClient.Me.GetAsync(); + _name = user?.DisplayName; + } + catch (Exception ex) + { + ConsentHandler.HandleException(ex); + } + } + + private async Task SendMessage() + { + if (!string.IsNullOrEmpty(_userInput)) + { + var ask = _userInput; + _userInput = string.Empty; + _messages.Add(new Message + { + MessageId = _messageCount++, + MessageText = ask, + Sender = Sender.User + }); + + var loadingMessage = new Message + { + MessageId = _messageCount++, + MessageText = "Thinking...", + Sender = Sender.Bot + }; + _messages.Add(loadingMessage); + try + { + var response = await TimesheetService.ChatWithGraph(ask); + loadingMessage.MessageText = response ?? "⚠️ error"; + } + catch (Exception e) + { + loadingMessage.MessageText = "⚠️ error"; + Console.WriteLine(e); + throw; + } + } + } +} \ No newline at end of file diff --git a/src/TimesheetGPT.WebUI/Pages/Index.razor b/src/TimesheetGPT.WebUI/Pages/Index.razor index 2a0e67c..43eaf3c 100644 --- a/src/TimesheetGPT.WebUI/Pages/Index.razor +++ b/src/TimesheetGPT.WebUI/Pages/Index.razor @@ -1,9 +1,6 @@ @page "/" +@using TimesheetGPT.WebUI.Models -@using Microsoft.Identity.Web -@using Microsoft.Graph -@using TimesheetGPT.Core.Models -@using TimesheetGPT.Core.Services @inject GraphServiceClient GraphServiceClient @inject MicrosoftIdentityConsentAndConditionalAccessHandler ConsentHandler @inject TimesheetService TimesheetService @@ -21,11 +18,11 @@ Variant="Variant.Outlined" IsDateDisabledFunc="@((DateTime dt) => dt.Date > DateTime.Now.Date)" Editable="true"/> - + - @@ -37,110 +34,47 @@ StartIcon="@Icons.Material.Filled.PostAdd" Disabled="_loading" @onclick="GenerateTimesheet"> - @_generateButtonText + @if (_hasBeenGenerated) + { + Regenerate + } + else + { + Generate + } - - @if (additionalNotesError) + + @if (_additionalNotesError) { Additional notes too long. You have @_additionalNotes.Length characters. We only accept 400 max. } - + - - @if (_loading) - { - - - - } - else - { - - - @if (_emailSubjects is not null && _emailSubjects?.Count > 1 && !_loading) - { - - -
    - @foreach (var subject in _emailSubjects.Where(m => !string.IsNullOrEmpty(m))) - { -
  • @subject
  • - } -
-
-
- } - - @if (!_loading && _meetings != null) - { - var meetingsMiddleBreak = "12"; - if (_emailSubjects is not null && _emailSubjects?.Count > 1 && !_loading) - { - meetingsMiddleBreak = "6"; - } - - -
    - @foreach (var meeting in _meetings) - { - string formatted; - - var timeSpan = meeting.Length; - if (timeSpan.Hours > 0) - { - formatted = $"{timeSpan.Hours} hours {timeSpan.Minutes} minutes"; - } - else - { - formatted = $"{timeSpan.Minutes} minutes"; - } - - @meeting.Name - @formatted - } -
-
-
- } - - @if (!_loading && _summaryText != null) - { - - - - - - - } -
- } -
+ @code { bool _loading; - IList? _emailSubjects; - IList? _meetings; - string? _summaryText; DateTime? _date = DateTime.Today; string? _name = "..."; string? _extraPrompt; string _additionalNotes = string.Empty; - string _generateButtonText = "Generate"; + bool _hasBeenGenerated; + ResultVm _results = new ResultVm(); - bool additionalNotesError; + bool _additionalNotesError; - protected override async Task OnInitializedAsync() + protected async override Task OnInitializedAsync() { try { @@ -156,35 +90,43 @@ private async Task GenerateTimesheet() { _loading = true; + _results.Loading = true; try { - additionalNotesError = false; - additionalNotesError = CheckAdditionalNotesIsntTooBig(); - if (additionalNotesError) + _additionalNotesError = false; + _additionalNotesError = CheckAdditionalNotesLength(); + if (_additionalNotesError) { return; } var dateTime = _date ?? DateTime.Today; + + _results = new ResultVm + { + Loading = _loading, + }; + var summary = await TimesheetService.GenerateSummary(dateTime, _extraPrompt ?? "", _additionalNotes); - _emailSubjects = summary.Emails; - _meetings = summary.Meetings; - _summaryText = summary.Summary; + _results.Meetings = summary.Meetings; + _results.Emails = summary.Emails; + _results.SummaryText = summary.Text; - _generateButtonText = "Regenerate"; + _hasBeenGenerated = true; } - catch (Exception ex) //TODO: straight from the template, but should be more specific? + catch (Exception ex) { ConsentHandler.HandleException(ex); } finally { + _results.Loading = false; _loading = false; } - + } - - private bool CheckAdditionalNotesIsntTooBig() + + private bool CheckAdditionalNotesLength() { return _additionalNotes.Length > 400; } diff --git a/src/TimesheetGPT.WebUI/Shared/MainLayout.razor b/src/TimesheetGPT.WebUI/Shared/MainLayout.razor index 694c6df..5e57c93 100644 --- a/src/TimesheetGPT.WebUI/Shared/MainLayout.razor +++ b/src/TimesheetGPT.WebUI/Shared/MainLayout.razor @@ -77,6 +77,14 @@ { Typography = new Typography() { + H1 = new H1() + { + FontSize = "5rem" + }, + H2 = new H2() + { + FontSize = "4rem" + }, Body2 = new Body2() { FontWeight = 600, diff --git a/src/TimesheetGPT.WebUI/TimesheetGPT.WebUI.csproj b/src/TimesheetGPT.WebUI/TimesheetGPT.WebUI.csproj index a10ad7a..ca63faa 100644 --- a/src/TimesheetGPT.WebUI/TimesheetGPT.WebUI.csproj +++ b/src/TimesheetGPT.WebUI/TimesheetGPT.WebUI.csproj @@ -15,14 +15,15 @@ - - - - - - - + + + + + + + + diff --git a/src/TimesheetGPT.WebUI/_Imports.razor b/src/TimesheetGPT.WebUI/_Imports.razor index ef40cc0..1ef8654 100644 --- a/src/TimesheetGPT.WebUI/_Imports.razor +++ b/src/TimesheetGPT.WebUI/_Imports.razor @@ -8,4 +8,9 @@ @using Microsoft.JSInterop @using TimesheetGPT.WebUI @using TimesheetGPT.WebUI.Shared -@using MudBlazor \ No newline at end of file +@using MudBlazor +@using Microsoft.Identity.Web +@using Microsoft.Graph +@using TimesheetGPT.Core.Models +@using TimesheetGPT.Core.Services +@using TimesheetGPT.WebUI.Components \ No newline at end of file