<?xml version="1.0" encoding="utf-8"?>
<AlvaoApplication xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ModelVersion="1">
  <Applications>
    <Application id="1063">
      <Name>Service Desk AI Assistant - daily summary</Name>
      <Description>Send daily summaries to service managers and solvers that are in insider preview.
Version: 1.1

Change log:
* 1.1
- do not send empty summaries
- fix email format

* 1.0 
- initial version</Description>
        <UniqueId>1397A5F4-96C0-4C97-A70A-B7DC85336817</UniqueId>
        <Version>2</Version>
      <AdvancedSettings />
      <Scripts>
        <Script id="1262">
          <Name>SendSummary</Name>
          <Code>using System;
using Microsoft.Data.SqlClient;
using Alvao.Apps.API;
using Alvao.API.AI;
using Alvao.API.Common;
using Dapper;
using Alvao.Context;
using System.Linq;
using System.Collections.Generic;
using Alvao.Context.DB;

public class SendSummary : IPeriodicAction
{
    public string Name
    {
        get =&gt; "SendSummary";
        set { }
    }

    public void OnPeriod(SqlConnection con)
    {
        try
        {
            int currentHour = DateTime.Now.Hour;
            // do not send on weekends and outside specified hour
            if (DateTime.Now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || currentHour != Settings.Hour) return;

            var dailySummary = new DailySummary();

            // start time is 24 hours ago or on friday if today is monday
            var utcNow = DateTime.UtcNow;
            var endTime = new DateTime(
                utcNow.Year,
                utcNow.Month,
                utcNow.Day,
                utcNow.Hour,
                0, 0, 0,
                DateTimeKind.Utc
            );
            var startTime = endTime.AddDays(utcNow.DayOfWeek == DayOfWeek.Monday ? -3 : -1);
            // values for testing
            // var startTime = new DateTime(2023, 8, 1);
            // var utcNow = new DateTime(2023, 8, 8);

            dailySummary.Send(startTime, utcNow);
        }
        catch (Exception ex)
        {
            Log.Error("OnPeriod", ex, "Failed");
        }
    }
}</Code>
          <IsLibCode>false</IsLibCode>
          <Codesign>nFk1gzjDuFTd5sM8RFDDLvP80Oyip5F+0XgM8Zrd9CMgDRgprAq2D98zNki4tRSSt74Iajm8ExUMGxlfHAH66L3dwT6RB1V6/HjOHXGcTifDUdDA6rCtRdSmmBDupUyQZZHSes75648v1DUCPxxfoHTnUf44RH3J0BrEPm2hjSJ5Q+3gY7kuNhdj0/QlRWZHa22f9XjDiDS93slsPIE4VPeECWvtmbxeGU9CTvC+vZn0wBz3OmBGQx4mItdF6z70U4qPfK54c7mFvnnsPXNJwj6NaN4mbAIVuD9RlwpqZn/dnvVcMI7nxBVpVI3ANREu/gtvn8rZre6cSYKOsWPYag==</Codesign>
        </Script>
        <Script id="1263">
          <Name>CommonLib</Name>
          <Code>using Alvao.API.AI;
using Alvao.API.AI.Model;
using Alvao.API.Common;
using Alvao.API.Common.Model.Database;
using Alvao.API.SD;
using Alvao.Context;
using Alvao.Global;
using Dapper;
using Dapper.Contrib.Extensions;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Web;
using static Alvao.API.Common.Model.Database.tActKind;

public class Settings
{
    public const int Hour = 5;

    public const string ChatGptModelName = "gpt-4o";
    public const int MaxResponseTokenCount = 4000;
    public const bool UseStructuredChatData = true;

    public const string AzureUrl = "https://alvao-ai-uk.openai.azure.com/";
    public const string AzureKey = "Erar8w7jIclDrj7weVyb397W2XiniyuWI9StbFFQbRiJBUw6YX4AJQQJ99BGACmepeSXJ3w3AAABACOGoZuz";
    public const string ApiVersion = "2024-08-01-preview";

    // set the LogDirectoryPath to empty string to log to database table TenantDiagnosticsLog
    // otherwise text logs are written to specified directory
    public const string LogDirectoryPath = @""; // C:\tmp\Logs
    public const string LogNamePrefix = @"DailySummary";

    public const int MaxUserCount = 100;
    public const int MaxTicketCountPerUser = 50;
}

public class DailySummary
{
    private static readonly Regex AlvaoMessageSeparator = new("&lt;hr id=\"origmsg\"&gt;", RegexOptions.IgnoreCase);
    private static readonly Regex OutlookOnlineMessageSeparator = new("&lt;hr[^&gt;]+tabindex=\"-1\"", RegexOptions.IgnoreCase);
    private static readonly Regex OutlookDesktopMessageSeparator = new("&lt;div\\s+style=\"[^\"]*border-top:[^;]*solid", RegexOptions.IgnoreCase);
    private static readonly Regex MessageReferenceEn = new("This message refers to request[^\\.]*\\.");
    private static readonly Regex MessageReferenceCs = new("Tato zpráva se týká požadavku[^\\.]*\\.");

    private readonly CustomAIClient client = new CustomAIClient();
    private int systemPersonId;
    private readonly ITexts EnglishTexts = new EnglishTexts();
    private readonly ITexts CzechTexts = new CzechTexts();

    public void Send(DateTime startTime, DateTime endTime)
    {
        var start = DateTime.Now;
        Log.Info("Send", "Started {startTime} - {endTime}", startTime, endTime);

        systemPersonId = Alvao.API.Common.Person.GetSystem().iPersonId;
        string webAppUrl = DbProperty.WebAppUrl.TrimEnd('/');

        var persons = GetPersonsToNotify();
        int personIndex = 0;
        foreach (var person in persons)
        {
            personIndex++;
            var acts = GetActsForUser(person.iPersonId, startTime, endTime);
            var notifyPerson = new NotifyPerson()
            {
                Person = person,
                Culture = Alvao.API.Common.Person.GetCultureInfoOrDefault(person),
            };
            var tickets = GetTicketsById(acts.Select(a =&gt; a.liActHdTicketId ?? 0).Distinct(), notifyPerson.Culture.LCID)
                .ToDictionary(t =&gt; t.Id);

            int count = 0;
            foreach (var group in acts.GroupBy(a =&gt; a.liActHdTicketId ?? 0))
            {
                if (!tickets.TryGetValue(group.Key, out var ticket))
                {
                    continue;
                }

                ticket.Url = $"{webAppUrl}/Ticket/{ticket.Id}";
                var ticketSummary = new TicketSummary()
                {
                    Ticket = ticket,
                    Acts = group.ToList(),
                    Person = notifyPerson,
                };

                AddMessagesToTicket(ticketSummary);
                if (ticketSummary.Ticket.Messages.Count == 0)
                {
                    continue;
                }

                notifyPerson.Summaries.Add(ticketSummary);
                count++;
                if (count &gt;= Settings.MaxTicketCountPerUser)
                {
                    Log.Info("Send", "Maximum {maxTickets} tickets per person reached for {personEmail}", Settings.MaxTicketCountPerUser, person.PersonEmail);
                    break;
                }
            }

            if (notifyPerson.Summaries.Count == 0)
            {
                // Log.Info("Send", "No summaries for {personEmail}", person.PersonEmail);
                continue;
            }

            // Get structured summary from tickets
            GetStructuredSummaryFromTickets(notifyPerson);

            SendEmail(notifyPerson, startTime, endTime);
            if (personIndex &gt;= Settings.MaxUserCount)
            {
                Log.Info("Send", "Maximum {MaxUserCount} persons processed", Settings.MaxUserCount);
                break;
            }
        }

        UsageHelper.WriteToLog();
        Log.Info("Send", "Finished {duration}", DateTime.Now - start);
    }

    private IEnumerable&lt;tPerson&gt; GetPersonsToNotify()
    {
        if (!DbProperty.InsiderPreview) return [];

        var role = Role.GetSystemRole(RoleBehavior.BehaviorId.InsiderPreviewTesters);
        if (role == null) return [];

        return Role.GetMembers(role.iRoleId, onlyWithEmail: true).Where(p =&gt; !p.IsOutOfOffice);
    }

    private void SendEmail(NotifyPerson person, DateTime startTime, DateTime endTime)
    {
        var summaries = person.Summaries;
        if (summaries.Count == 0) return;

        var address = person.Person.PersonEmail;
        if (string.IsNullOrEmpty(address))
        {
            return;
        }

        // Log.Info("SendEmail", "Person {personEmail}", address);
        ITexts texts = GetTextsForPerson(person);
        string subject = string.Format(texts.Subject, startTime.ToShortDateString());
        var html = GetMailHtml(person, startTime, endTime, texts, false);
        if (string.IsNullOrEmpty(html))
        {
            return;
        }
        // SaveMailToFile(address, html, false);

        var mail = new Rebex.Mail.MailMessage
        {
           DefaultCharset = Encoding.UTF8,
           Subject = subject,
           BodyHtml = html
        };

        mail.From.Add(DbProperty.AlvaoStandardSenderAddress);
        mail.To.Add(address);
        Email.Queue(mail);

        // html = GetMailHtml(person, startTime, endTime, texts, true);
        // SaveMailToFile(address, html, true);

        // Log.Info("SendEmail", "Email sent");
    }

    private ITexts GetTextsForPerson(NotifyPerson person)
    {
        return person.Culture.TwoLetterISOLanguageName == "cs" ? CzechTexts : EnglishTexts;
    }

    private static string GetMailHtml(NotifyPerson person, DateTime startTime, DateTime endTime, ITexts texts, bool detailed)
    {
        bool hasText = false;
        var summaries = person.Summaries
            .OrderBy(s =&gt; s.Ticket.ServiceName)
            .ThenBy(s =&gt; s.Ticket.GetIconNumber(startTime))
            .ToList();

        var html = new StringBuilder();
        html.AppendLine("&lt;html&gt;");
        html.AppendLine("&lt;head&gt;");
        html.AppendLine("&lt;meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"&gt;");
        html.AppendLine($"&lt;style&gt;{MailSettings.Css}&lt;/style&gt;");
        html.AppendLine("&lt;/head&gt;");
        html.AppendLine("&lt;body&gt;");
        html.AppendLine("&lt;p&gt;");
        html.AppendLine(texts.Greeting);
        html.AppendLine("&lt;/p&gt;");
        html.AppendLine("&lt;p&gt;");
        html.AppendLine(string.Format(texts.Introduction, $"{startTime.ToShortDateString()}"));
        html.AppendLine("&lt;/p&gt;");

        var ticketsNeedingAttention = summaries
            .Where(s =&gt; !string.IsNullOrEmpty(s.ReasonForAttention))
            .ToList();

        if (ticketsNeedingAttention.Count &gt; 0)
        {
            html.AppendLine("&lt;div class=\"section\"&gt;");
            html.AppendLine($"&lt;h1&gt;⚠️ {texts.AttentionNeeded}&lt;/h1&gt;");
            html.AppendLine("&lt;ul&gt;");
            foreach (var ticket in ticketsNeedingAttention)
            {
                var ticketHtml = ticket.GetAttentionNeededHtml();
                html.AppendLine(ticketHtml);
                hasText = true;
            }
            html.AppendLine("&lt;/ul&gt;");
            html.AppendLine("&lt;/div&gt;");
        }

        html.AppendLine("&lt;div class=\"section\"&gt;");
        html.AppendLine($"&lt;h1&gt;{texts.TicketUpdates}&lt;/h1&gt;");
        foreach (var ticket in summaries)
        {
            if (string.IsNullOrWhiteSpace(ticket.Summary)) continue;

            var ticketHtml = ticket.GetHtml(detailed, texts, startTime);
            if (!string.IsNullOrEmpty(ticketHtml))
            {
                html.AppendLine(ticketHtml);
                hasText = true;
            }
        }
        html.AppendLine("&lt;/div&gt;");

        html.AppendLine("&lt;div class=\"footer\"&gt;");
        html.AppendLine(texts.Footer);
        html.AppendLine("&lt;/div&gt;");
        html.AppendLine("&lt;/body&gt;&lt;/html&gt;");

        return hasText ? html.ToString() : string.Empty;
    }

    private void SaveMailToFile(string email, string html, bool detailed)
    {
        try
        {
            string directory = Path.Combine(Settings.LogDirectoryPath, "Mail");
            if (detailed)
            {
                directory = Path.Combine(directory, "Detailed");
            }

            if (!Directory.Exists(directory))
            {
                Directory.CreateDirectory(directory);
            }
            string path = Path.Combine(directory, $"{DateTime.Now:yyyy-MM-dd}-{email}.html");
            File.WriteAllText(path, html);
        }
        catch (Exception ex)
        {
            Log.Warn("SaveMailToFile", ex, "Failed to save email to file");
        }
    }

    private static void AppendSectionSummaryHtml(
        StringBuilder html,
        NotifyPerson person,
        string serviceName,
        List&lt;TicketSummary&gt; tickets,
        ITexts texts,
        DateTime startDate,
        bool showConversation)
    {
        html.AppendLine($"&lt;h2&gt;Service {HttpUtility.HtmlEncode(serviceName)}&lt;/h2&gt;");
        AppendTicketsHtml(html, showConversation, tickets, "", texts, startDate);

        //var newTickets = tickets.Where(t =&gt; t.Ticket.Created &gt; startDate &amp;&amp; t.Ticket.Resolved == null).ToList();
        //var resolvedTickets = tickets.Where(t =&gt; t.Ticket.Resolved != null).ToList();
        //var newOrResolvedTicketIds = new HashSet&lt;int&gt;(newTickets.Union(resolvedTickets).Select(t =&gt; t.Ticket.Id));
        //var otherTickets = tickets.Where(t =&gt; !newOrResolvedTicketIds.Contains(t.Ticket.Id)).ToList();
        //AppendTicketsHtml(html, showConversation, newTickets, "New tickets");
        //AppendTicketsHtml(html, showConversation, otherTickets, "Updates");
        //AppendTicketsHtml(html, showConversation, resolvedTickets, "Resolved tickets");
    }

    private static void AppendTicketsHtml(StringBuilder html, bool showConversation, List&lt;TicketSummary&gt; tickets, string title, ITexts texts, DateTime startTime)
    {
        if (tickets.Count == 0)
        {
            return;
        }

        var ticketsHtml = new StringBuilder();
        foreach (var ticket in tickets)
        {
            if (string.IsNullOrWhiteSpace(ticket.Summary)) continue;

            var ticketHtml = ticket.GetHtml(showConversation, texts, startTime);
            if (!string.IsNullOrEmpty(ticketHtml))
                ticketsHtml.AppendLine(ticketHtml);
        }

        if (ticketsHtml.Length &gt; 0)
        {
            if (!string.IsNullOrEmpty(title))
            {
                html.AppendLine($"&lt;h3&gt;{title}&lt;/h3&gt;");
            }

            html.AppendLine(ticketsHtml.ToString());
        }
    }

    private void AddMessagesToTicket(TicketSummary ticketSummary)
    {
        var personId = ticketSummary.Person.Person.iPersonId;
        foreach (var act in ticketSummary.Acts)
        {
            ActKind kind = (ActKind)(act.liActKindId ?? -1);
            if (kind == ActKind.Empty)
            {
                continue;
            }

            bool removeParameterTable = ticketSummary.Ticket.StartingActId == act.iActId;
            string actText = StripHtml(act.ActHtml, removeParameterTable);
            if (string.IsNullOrWhiteSpace(actText))
            {
                continue;
            }

            ticketSummary.Ticket.Messages.Add(new MessageData()
            {
                From = act.sActFrom,
                To = act.sActTo,
                Title = act.sAct,
                Text = actText,
                Type = kind.ToString(),
                IsImportant = act.ActMarkId == 1,
                IsSolution = act.ActMarkId == 2,
                IsSentByCurrentUser = act.liActFromPersonId == personId,
            });
        }
    }

    private IEnumerable&lt;tAct&gt; GetActsForUser(int personId, DateTime startTime, DateTime endTime)
    {
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.Query&lt;tAct&gt;(@"
SELECT a.*
FROM
(
    -- Tickets to be resolved
    SELECT
        t.iHdTicketId
    FROM tHdTicket AS t
        INNER JOIN vHdTicketPersonRead rights ON rights.liHdTicketId = t.iHdTicketId and rights.liPersonId = @personId
    WHERE
        (
            (rights.Operator = 1 and t.liHdTicketSolverPersonId is null and t.SolverGroupRoleId is null) 
		    or t.liHdTicketSolverPersonId = @personId
            or
            (
                t.SolverGroupRoleId is not null 
                and exists(
                    select top 1 1 from tRolePerson
                    where liRolePersonPersonId = @personId 
                        and liRolePersonRoleId = t.SolverGroupRoleId
                )
                and (t.liHdTicketSolverPersonId is null or t.liHdTicketSolverPersonId = @personId)
            )
        )

    UNION

    -- Tickets where the user is manager of service
    SELECT t.iHdTicketId
    FROM tHdTicket t
	    INNER JOIN tRolePerson rp on rp.liRolePersonPersonId = t.liHdTicketUserPersonId
	    INNER JOIN HdSectionManager m ON m.HdSectionId = t.liHdTicketHdSectionId and m.RequesterRoleId = rp.liRolePersonRoleId and m.PersonId = @personId
) AS t2
INNER JOIN tAct AS a ON a.liActHdTicketId = t2.iHdTicketId
	and a.dAct &gt; @startTime and a.dAct &lt;= @endTime
	and a.dActRemoved is null
	and not (a.liActKindId = 1 /* email */ and (a.liActFromPersonId = @systemPersonId or a.liActFromPersonId is null))
ORDER BY a.liActHdTicketId, a.dAct
", new { systemPersonId, personId, startTime, endTime }, scope.Transaction);
    }

    private IEnumerable&lt;TicketData&gt; GetTicketsById(IEnumerable&lt;int&gt; ticketIds, int localeId)
    {
        if (ticketIds == null || !ticketIds.Any()) return [];
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.Query&lt;TicketData&gt;(@"
SELECT
    HDT.iHdTicketId AS Id,
    HDT.sHdTicketMessageTag AS Tag,
    HDT.sHdTicket AS Title,
    HDT.liHdTicketStartingActId AS StartingActId,
    HDT.dHdTicket AS Created,
    HDT.ClosedDate,
    HDT.dHdTicketResolved AS Resolved,
    TSL.TicketState AS Status,
    HDSL.HdSection AS ServiceName,
    PUSER.sPerson AS Requester,
    PSOLVER.sPerson AS Solver,
    RSOLVERGROUP.sRole AS SolverGroup
FROM tHdTicket AS HDT
LEFT JOIN tHdSection HDS on HDS.iHdSectionId=HDT.liHdTicketHdSectionId
LEFT JOIN TicketState TS on TS.id = HDT.TicketStateId
LEFT JOIN HdSectionLoc HDSL on HDSL.HdSectionId=HDS.iHdSectionId and HDSL.LocaleId = @localeId
LEFT JOIN TicketTypeLoc TT on TT.TicketTypeId=HDS.TicketTypeId AND TT.LocaleId = @localeId
LEFT JOIN TicketStateLoc TSL on TSL.TicketStateId = HDT.TicketStateId and TSL.LocaleId = @localeId
LEFT JOIN tPerson AS PUSER ON PUSER.iPersonId=HDT.liHdTicketUserPersonId
LEFT JOIN tPerson AS PSOLVER ON PSOLVER.iPersonId=HDT.liHdTicketSolverPersonId
LEFT JOIN tRole AS RSOLVERGROUP ON RSOLVERGROUP.iRoleId=HDT.SolverGroupRoleId
WHERE HDT.iHdTicketId in @ticketIds
", new { ticketIds, localeId }, scope.Transaction);
    }

    private void GetSummaryFromCommunication(TicketSummary ticketSummary)
    {
        if (ticketSummary.Ticket.Messages.Count == 0)
        {
            return;
        }

        var response = SummarizeTicketCommunication(ticketSummary);
        ticketSummary.Summary = response.Summary;
        ticketSummary.Error = response.Error;
    }

    private string GetSummaryFromTickets(NotifyPerson person)
    {
        var tickets = person.Summaries.Select(s =&gt; s.Ticket).OrderBy(t =&gt; t.ServiceName).ThenBy(t =&gt; t.Id).ToList();
        string jsonData = JsonConvert.SerializeObject(tickets, Formatting.Indented);
        var texts = GetTextsForPerson(person);
        string systemMessage = @$"
You are an AI assistant that creates a summary from messages
arrived to the ticket in the Service Desk system.
The summary will be sent to the user as a part of a report of what happened in the tickets.

Input is a JSON object with tickets and arrived messages.

Summarize the ticket communication in a concise way.
Write the summary as you would report it to user {person.Person.sPerson} in the {person.Culture.EnglishName} language,
but do not add any greetings or closing statements.

The generated text must be in HTML format. Example output:

```html
&lt;h2&gt;Service Name&lt;/h2&gt;
&lt;h3&gt;&lt;a href=""ticket.Url""&gt;ticket.Tag&lt;/a&gt; - title&lt;/h3&gt;
&lt;p&gt;Summary of the ticket communication.&lt;/p&gt;
```

Do not mention greetings or closing statements that occur in mesages in the summary of tickets.
Prioritize messages marked as important or solution, but don't mention in the summary that they were marked as such.
Resolving of child ticket does not mean that the parent ticket is resolved.

Add chapter with title ""{texts.AttentionNeeded}""
that will contain text with tickets that need more attention and clarify the reasons.
Do not add this chapter if there are no such tickets.

In case that data cannot be retrieved then return string ""-"".
";
        var data = new
        {
            max_tokens = Settings.MaxResponseTokenCount,
            messages = new List&lt;object&gt;()
            {
                new
                {
                    role = "system",
                    content = systemMessage
                },
                new
                {
                    role = "user",
                    content = jsonData
                }
            }
        };

        string outputText = client.GetChatResponse(Settings.ChatGptModelName, data, true);
        if (string.IsNullOrEmpty(outputText) || outputText == "-")
        {
            return null;
        }

        return StripCodeFence(outputText);
    }

    private void GetStructuredSummaryFromTickets(NotifyPerson person)
    {
        var tickets = person.Summaries.Select(s =&gt; s.Ticket).OrderBy(t =&gt; t.ServiceName).ThenBy(t =&gt; t.Id).ToList();
        string jsonData = JsonConvert.SerializeObject(tickets, Formatting.Indented);
        var texts = GetTextsForPerson(person);
        string systemMessage = @$"
You are an AI assistant that creates a summary from messages
arrived to the ticket in the Service Desk system.
The summary will be sent to the user as a part of a report of what happened in the tickets.

Input is a JSON object with tickets and arrived messages.

Summarize the ticket communication in a concise way.
Write the summary as you would report it to user {person.Person.sPerson} in the {person.Culture.EnglishName} language,
but do not add any greetings or closing statements.
Do not add text from messages that were sent by current user (IsSentByCurrentUser = true) to the summary.

Prioritize messages marked as important or solution, but don't mention in the summary that they were marked as such.
Resolving of child ticket does not mean that the parent ticket is resolved.

Fill in reasonForAttention property for tickets that need attention from side of user {person.Person.sPerson} and clarify the reasons.
Write the text in the user language {person.Culture.EnglishName} and in the form what should he do.
Leave this property empty if the user does not have to react or do any activity based on incoming messages.
";
        var data = new
        {
            max_tokens = Settings.MaxResponseTokenCount,
            messages = new List&lt;object&gt;()
            {
                new
                {
                    role = "system",
                    content = systemMessage
                },
                new
                {
                    role = "user",
                    content = jsonData
                }
            },
            response_format = new
            {
                type = "json_schema",
                json_schema = new
                {
                    name = "TicketSummaryResponse",
                    strict = true,
                    schema = new
                    {
                        type = "object",
                        description = "Data retrieved from ticket conversation",
                        properties = new
                        {
                            tickets = new
                            {
                                type = "array",
                                description = "List of summaries created from messages of tickets",
                                items = new
                                {
                                    type = "object",
                                    description = "Summary of one ticket",
                                    properties = new
                                    {
                                        id = new { type = "integer", description = "Ticket identifier" },
                                        summary = new { type = "string", description = "Summary text in user language" },
                                        reasonForAttention = new { type = "string", description = "Text in user language that describes reason why it needs user attention. Do not fill if the attention is not needed." },
                                    },
                                    required = new[] { "id", "summary", "reasonForAttention" },
                                    additionalProperties = false,
                                }
                            },
                        },
                        required = new[] { "tickets" },
                        additionalProperties = false,
                    }
                }
            }
        };

        string json = client.GetChatResponse(Settings.ChatGptModelName, data, true);
        if (string.IsNullOrEmpty(json))
        {
            Log.Warn("GetStructuredSummaryFromTickets", "No response from AI service.");
            return;
        }

        var response = JsonConvert.DeserializeObject&lt;TotalTicketSummaryResponse&gt;(json);
        if (response == null)
        {
            Log.Warn("GetStructuredSummaryFromTickets", "Failed to deserialize AI response.");
            return;
        }
        
        foreach (var ticketSummary in person.Summaries)
        {
            var ticketResponse = response.Tickets?.FirstOrDefault(t =&gt; t.Id == ticketSummary.Ticket.Id);
            if (ticketResponse != null)
            {
                ticketSummary.Summary = ticketResponse.Summary;
                if (!string.IsNullOrEmpty(ticketResponse.ReasonForAttention))
                {
                    ticketSummary.ReasonForAttention = ticketResponse.ReasonForAttention;
                }
            }
        }
    }

    private TicketSummaryResponse SummarizeTicketCommunication(TicketSummary ticketSummary)
    {
        string jsonData = JsonConvert.SerializeObject(ticketSummary.Ticket, Formatting.Indented);
        string systemMessage = @$"
You are an AI assistant that should create summary from messages
arrived to the ticket in the Service Desk system.
Input is a JSON object with ticket data and messages.
Summarize the ticket communication in a concise way. Do not repeat ticket title or other information that is already in the ticket header.
Write the summary as a report to the user {ticketSummary.Person.Person.sPerson} in the {ticketSummary.Person.Culture.EnglishName} language.
Do not mention greetings or closing statements.
Prioritize messages marked as important or solution, but don't mention in the summary that they were marked as such.
Resolving of child ticket does not mean that the parent ticket is resolved.
In case that data cannot be extracted then set the property error in the returned JSON object with the reason.
";
        var data = new
        {
            max_tokens = Settings.MaxResponseTokenCount,
            messages = new List&lt;object&gt;()
            {
                new
                {
                    role = "system",
                    content = systemMessage
                },
                new
                {
                    role = "user",
                    content = jsonData
                }
            },
            response_format = new
            {
                type = "json_schema",
                json_schema = new
                {
                    name = "TicketSummaryResponse",
                    strict = true,
                    schema = new
                    {
                        type = "object",
                        description = "Data retrieved from ticket conversation",
                        properties = new
                        {
                            summary = new { type = "string", description = "Summary text in user language." },
                            error = new { type = "string", description = "Contains text of error if the data cannot be retrieved." },
                        },
                        required = new[] { "summary", "error" },
                        additionalProperties = false,
                    }
                }
            }
        };

        string json = client.GetChatResponse(Settings.ChatGptModelName, data, true);
        if (string.IsNullOrEmpty(json))
        {
            return new TicketSummaryResponse() { Error = "No response from AI service." };
        }

        var response = JsonConvert.DeserializeObject&lt;TicketSummaryResponse&gt;(json);
        if (response == null)
        {
            return new TicketSummaryResponse() { Error = "Failed to deserialize AI response." };
        }

        return response;
    }

    private void CreateTotalSummary(NotifyPerson person)
    {
        // use person.Summaries to create total summary for the person
        if (person.Summaries.Count == 0) return;
        var dataSummaries = person.Summaries.Select(s =&gt; new TicketSummaryData(s)).ToList();

        string jsonData = JsonConvert.SerializeObject(dataSummaries, Formatting.Indented);
        var texts = GetTextsForPerson(person);
        string systemMessage = @$"
You are an AI assistant that creates a summary from messages
arrived to the ticket in the Service Desk system.
The summary will be sent to the user as a part of a report of what happened in the tickets.

Input is a JSON object with tickets and arrived messages.

Summarize the ticket communication in a concise way.
Write the summary as you would report it to user {person.Person.sPerson} in the {person.Culture.EnglishName} language,
but do not add any greetings or closing statements.

The generated text must be in HTML format. Example output:

```html
&lt;h2&gt;Service Name&lt;/h2&gt;
&lt;h3&gt;&lt;a href=""TicketUrl""&gt;TicketTag&lt;/a&gt; - title&lt;/h3&gt;
&lt;p&gt;Summary of the ticket communication.&lt;/p&gt;
```

Do not mention greetings or closing statements that occur in mesages in the summary of tickets.
Prioritize messages marked as important or solution, but don't mention in the summary that they were marked as such.
Resolving of child ticket does not mean that the parent ticket is resolved.

Add chapter with title ""{texts.AttentionNeeded}""
that will contain text with tickets that need more attention and clarify the reasons.
Do not add this chapter if there are no such tickets.

In case that data cannot be retrieved then return string ""-"".
";
        var data = new
        {
            max_tokens = Settings.MaxResponseTokenCount,
            messages = new List&lt;object&gt;()
            {
                new
                {
                    role = "system",
                    content = systemMessage
                },
                new
                {
                    role = "user",
                    content = jsonData
                }
            }
        };

        string html = client.GetChatResponse(Settings.ChatGptModelName, data);
        if (string.IsNullOrEmpty(html) || html == "-")
        {
            return;
        }

        person.TotalSummary = StripCodeFence(html);
    }

    private static string StripCodeFence(string text)
    {
        if (string.IsNullOrEmpty(text)) return text;
        var codeFence = "```";
        int start = text.IndexOf(codeFence);
        if (start &gt;= 0)
        {
            int startFenceEnd = text.IndexOf('\n', start + codeFence.Length);
            int end = text.LastIndexOf(codeFence);
            if (end &gt; startFenceEnd)
            {
                return text.Substring(startFenceEnd, end - startFenceEnd).Trim();
            }
        }
        return text.Trim();
    }

    // Remove original message and sentense "This message refers to request xxx."
    // Put everything in one line
    private static string StripHtml(string html, bool removeParameterTable)
    {
        if (string.IsNullOrEmpty(html)) return html;

        if (removeParameterTable)
        {
            var tableEnd = "&lt;/table&gt;";
            var tableEndPosition = html.IndexOf(tableEnd);
            var descriptionStartPosition = html.IndexOf("&lt;p&gt;");
            if (descriptionStartPosition &lt; 0 &amp;&amp; tableEndPosition &gt;= 0) descriptionStartPosition = tableEndPosition + tableEnd.Length;
            if (tableEndPosition &gt; 0 &amp;&amp; tableEndPosition &lt;= descriptionStartPosition)
            {
                html = "&lt;html&gt;&lt;body&gt;&lt;div&gt;" + html.Substring(descriptionStartPosition);
            }
        }

        var separatorPosition = GetSeparatorPosition(html);
        if (separatorPosition &gt;= 0)
        {
            html = html.Substring(0, separatorPosition) + "&lt;/div&gt;&lt;/body&gt;&lt;/html&gt;";
        }

        var text = StringLib.StripHtml(html);
        text = MessageReferenceEn.Replace(text, string.Empty);
        text = MessageReferenceCs.Replace(text, string.Empty);
        text = text.ReplaceLineEndings(" ");
        return text;
    }

    private static int GetSeparatorPosition(string html)
    {
        var match = AlvaoMessageSeparator.Match(html);
        if (match.Success)
        {
            return match.Index;
        }

        match = OutlookOnlineMessageSeparator.Match(html);
        if (match.Success)
        {
            return match.Index;
        }

        match = OutlookDesktopMessageSeparator.Match(html);
        if (match.Success)
        {
            return match.Index;
        }

        return -1;
    }
}

public class CustomAIClient
{
    private static readonly Random RandomGenerator = new();

    public readonly List&lt;string&gt; Errors = new();
    public string LastErrorResponse { get; private set; }

    public HttpResponseMessage GetHttpResponse(string uri, object data)
    {
        string payload = JsonConvert.SerializeObject(data);
        using var content = new StringContent(payload, Encoding.UTF8, "application/json");
        using var client = new HttpClient();
        client.BaseAddress = new Uri(Settings.AzureUrl.TrimEnd('/') + "/openai/deployments/");
        client.DefaultRequestHeaders.Accept.Clear();
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        client.DefaultRequestHeaders.Add("api-key", Settings.AzureKey);

        int count = 0;
        HttpResponseMessage response = null;
        do
        {
            count++;
            try
            {
                response = client.PostAsync($"{uri}?api-version={Settings.ApiVersion}", content).Result;
                if (response.StatusCode == (HttpStatusCode)429) // HttpStatusCode.TooManyRequests
                {
                    int waitTime = RandomGenerator.Next(5000, 10000);
                    // try to get Retry-After header
                    if (response.Headers.TryGetValues("Retry-After", out var values))
                    {
                        if (int.TryParse(values?.FirstOrDefault(), out int seconds))
                        {
                            waitTime = seconds * 1000;
                        }
                        if (seconds &gt;= 60)
                        {
                            return response;
                        }
                    }
                    Thread.Sleep(waitTime);
                }
                else
                {
                    return response;
                }
            }
            catch (Exception ex)
            {
                Log.Warn("GetHttpResponse", ex, "Failed to get response for {uri}", uri);
            }
        } while (count &lt; 3);

        return response;
    }

    public T GetResponse&lt;T&gt;(string uri, object data)
    {
        var response = GetHttpResponse(uri, data);
        return GetResult&lt;T&gt;(response);
    }

    public string GetChatResponse(string model, object data, bool trimJson = false)
    {
        var result = GetResponse&lt;ChatCompletionCreateResponse&gt;($"{model}/chat/completions", data);
        if (result == null) return null;

        // Log.Debug("GetChatResponse", "Usage {usage}", result.Usage);
        UsageHelper.Add(model, result.Usage);
        string text = GetResponseText(result);
        if (!trimJson) return text;

        return TrimJson(text);
    }

    public EmbeddingCreateResponse GetEmbeddingResponse(string model, string text)
    {
        var data = new
        {
            model,
            input = text,
            dimensions = 1536
        };

        var result = GetResponse&lt;EmbeddingCreateResponse&gt;($"{model}/embeddings", data);
        if (result == null) return null;

        // Log.Debug("GetEmbeddingResponse", "Usage {usage}", result.Usage);
        UsageHelper.Add(model, result.Usage);
        return result;
    }

    public List&lt;float&gt; GetVector(string model, string text)
    {
        var response = GetEmbeddingResponse(model, text);
        if (response.Successful)
        {
            return response.Data[0].Embedding.ConvertAll(d =&gt; (float)d);
        }

        return null;
    }

    private T GetResult&lt;T&gt;(HttpResponseMessage response)
    {
        if (response == null) return default;

        string json = response.Content.ReadAsStringAsync().Result;
        if (!response.IsSuccessStatusCode)
        {
            LastErrorResponse = json;
            Errors.Add(json);
            Log.Warn("GetResult", "AI Response failed {json}", json);
            return default;
        }

        return JsonConvert.DeserializeObject&lt;T&gt;(json);
    }

    private static string GetResponseText(ChatCompletionCreateResponse response)
    {
        if (response.Successful)
        {
            return GetChoicesText(response.Choices);
        }
        else
        {
            return GetError(response.Error);
        }
    }

    private static string GetChoicesText(IEnumerable&lt;ChatChoiceResponse&gt; choices)
    {
        var text = new StringBuilder();
        foreach (var choice in choices)
        {
            if (choice.Message != null)
            {
                text.AppendLine(choice.Message.Content);
            }
        }

        text.Replace("[Your Name]", "Alvao System");
        return text.ToString();
    }

    private static string GetError(Error error)
    {
        if (error == null)
        {
            return "Unknown Error";
        }
        return $"{error.Code}: {error.Message}";
    }

    private static readonly Regex AddString = new(@"""\s*\+\s*""", RegexOptions.Compiled);
    private static string TrimJson(string json)
    {
        int index = json.IndexOf('{');
        if (index &gt; 0)
        {
            json = json.Substring(index);
        }

        int lastIndex = json.LastIndexOf('}');
        if (lastIndex &gt; 0 &amp;&amp; lastIndex &lt; json.Length - 1)
        {
            json = json.Substring(0, lastIndex + 1);
        }

        json = AddString.Replace(json, "");
        return json;
    }
}

public static class Log
{
    public enum LogLevel
    {
        Trace = 0,
        Debug,
        Info,
        Warn,
        Error,
        Fatal,
        Off
    }

    private static readonly Regex argumentRegex = new Regex(@"\{[^\}]*\}");

    public static void Debug(string source, string message, params object[] args)
    {
        Write(LogLevel.Debug, source, null, message, args);
    }

    public static void Info(string source, string message, params object[] args)
    {
        Write(LogLevel.Info, source, null, message, args);
    }

    public static void Warn(string source, string message, params object[] args)
    {
        Write(LogLevel.Warn, source, null, message, args);
    }

    public static void Warn(string source, Exception exception, string message, params object[] args)
    {
        Write(LogLevel.Warn, source, exception, message, args);
    }

    public static void Error(string source, string message, params object[] args)
    {
        Write(LogLevel.Error, source, null, message, args);
    }

    public static void Error(string source, Exception exception, string message, params object[] args)
    {
        Write(LogLevel.Error, source, exception, message, args);
    }

    private static void Write(LogLevel logLevel, string source, Exception exception, string message, params object[] args)
    {
        if (string.IsNullOrEmpty(Settings.LogDirectoryPath))
            WriteDbLog(logLevel, source, exception, message, args);
        else
            WriteJsonLog(logLevel, source, exception, message, args);
        // WriteTextLog(logLevel, source, exception, message, args);
    }

    private static void WriteDbLog(LogLevel logLevel, string source, Exception exception, string message, params object[] args)
    {
        try
        {
            var argValues = GetArgValues(message, args);
            using var scope = AlvaoContext.GetConnectionScope();
            TenantDiagnosticsLog model = new()
            {
                LogLevelId = (int)logLevel,
                LoggedDate = DateTime.UtcNow,
                Application = "CustomApp." + Settings.LogNamePrefix,
                Callsite = source,
                Message = ReplaceArgValues(message, argValues),
                Exception = GetExceptionDetails(exception),
                Parameters = JsonConvert.SerializeObject(argValues),
            };
            scope.Connection.Insert(model, transaction: scope.Transaction);
        }
        catch { }
    }

    private static string ReplaceArgValues(string template, Dictionary&lt;string, object&gt; argValues)
    {
        if (argValues == null || string.IsNullOrEmpty(template)) return template;

        string text = template;
        foreach (var entry in argValues)
        {
            text = text.Replace($"{{{entry.Key}}}", $"{entry.Value}");
        }

        return text;
    }

    private class LogRecord
    {
        public string time { get; set; }
        public string level { get; set; }
        public string source { get; set; }
        public string message { get; set; }
        public Dictionary&lt;string, object&gt; args { get; set; }
        public string exception { get; set; }

        public LogRecord()
        {
            time = DateTime.Now.ToString("o");
        }
    }

    private static void WriteJsonLog(LogLevel level, string source, Exception exception, string message, params object[] args)
    {
        try
        {
            string exceptionDetails = GetExceptionDetails(exception);
            var record = new LogRecord
            {
                level = level.ToString(),
                source = source,
                message = message,
                exception = GetExceptionDetails(exception),
                args = GetArgValues(message, args)
            };

            if (!Directory.Exists(Settings.LogDirectoryPath))
            {
                Directory.CreateDirectory(Settings.LogDirectoryPath);
            }

            string path = Path.Combine(Settings.LogDirectoryPath, $"{Settings.LogNamePrefix}-{DateTime.Now:yyyy-MM-dd}.json.log");
            using (var writer = new StreamWriter(path, true))
            {
                string line = JsonConvert.SerializeObject(record);
                writer.WriteLine(line);
            }
        }
        catch { }
    }

    private static string GetExceptionDetails(Exception exception)
    {
        if (exception == null) return null;

        var exceptionDetails = new StringBuilder();
        var ex = exception;
        int index = 0;
        while (ex != null)
        {
            if (index &gt; 0) exceptionDetails.AppendLine();
            index++;
            exceptionDetails.Append(index);
            exceptionDetails.Append(" - ");
            exceptionDetails.Append(ex.GetType().Name);
            exceptionDetails.Append(": ");
            exceptionDetails.AppendLine(ex.Message);
            exceptionDetails.Append(ex.StackTrace);
            ex = ex.InnerException;
        }

        return exceptionDetails.ToString();
    }

    private static Dictionary&lt;string, object&gt; GetArgValues(string message, params object[] args)
    {
        if (args == null || args.Length == 0)
        {
            return null;
        }

        var values = new Dictionary&lt;string, object&gt;();
        var matches = argumentRegex.Matches(message);
        for (int i = 0; i &lt; matches.Count &amp;&amp; i &lt; args.Length; i++)
        {
            string replaceString = matches[i].Value;
            var paramName = replaceString.Substring(1, replaceString.Length - 2);
            values[paramName] = args[i];
        }

        return values;
    }
}

public class TokenUsage
{
    [JsonProperty("promptTokens")]
    public long PromptTokens { get; set; }

    [JsonProperty("completionTokens")]
    public long CompletionTokens { get; set; }

    [JsonProperty("totalTokens")]
    public long TotalTokens { get; set; }
}

public class Statistics
{
    [JsonProperty("startTime")]
    public DateTime StartTime { get; set; } = DateTime.UtcNow;
    [JsonProperty("updateTime")]
    public DateTime UpdateTime { get; set; } = DateTime.UtcNow;
    [JsonProperty("dailyUsage")]
    public TokenUsage DailyUsage { get; set; } = new TokenUsage();
    [JsonProperty("totalUsage")]
    public TokenUsage TotalUsage { get; set; } = new TokenUsage();

    public void Add(UsageResponse usage)
    {
        TotalUsage.PromptTokens += usage.PromptTokens;
        if (usage.CompletionTokens.HasValue)
        {
            TotalUsage.CompletionTokens += usage.CompletionTokens.Value;
        }
        TotalUsage.TotalTokens += usage.TotalTokens;

        if (DateTime.UtcNow.Day != UpdateTime.Day)
        {
            DailyUsage = new TokenUsage();
        }

        DailyUsage.PromptTokens += usage.PromptTokens;
        if (usage.CompletionTokens.HasValue)
        {
            DailyUsage.CompletionTokens += usage.CompletionTokens.Value;
        }
        DailyUsage.TotalTokens += usage.TotalTokens;

        UpdateTime = DateTime.UtcNow;
    }
}

public class Limit
{
    [JsonProperty("maxTokenCount")]
    public int MaxTokenCount { get; set; }
}

public static class UsageHelper
{
    private const string PropertyName = "CustomApp.SDAIAssistantDailySummary.Statistics";
    private const string LimitPropertyName = "CustomApp.SDAIAssistantDailySummary.DailyLimits";

    private static readonly Dictionary&lt;string, Limit&gt; Limits = Load&lt;Dictionary&lt;string, Limit&gt;&gt;(LimitPropertyName);
    private static readonly object Sync = new object();

    public static Dictionary&lt;string, Statistics&gt; _statisticsByModel;
    public static Dictionary&lt;string, Statistics&gt; StatisticsByModel
    {
        get
        {
            if (_statisticsByModel != null) return _statisticsByModel;

            _statisticsByModel = Load&lt;Dictionary&lt;string, Statistics&gt;&gt;(PropertyName);
            return _statisticsByModel;
        }
    }

    public static void Add(string model, UsageResponse usage)
    {
        lock (Sync)
        {
            if (!StatisticsByModel.TryGetValue(model, out var s))
            {
                s = new Statistics();
                StatisticsByModel[model] = s;
            }
            s.Add(usage);
        }

        SaveStatistics();
    }

    public static void WriteToLog()
    {
        lock (Sync)
        {
            foreach (var kvp in StatisticsByModel)
            {
                var model = kvp.Key;
                var s = kvp.Value;
                Log.Info("UsageHelper", "Model {model} Daily: Prompt {prompt} Completion {completion} Total {total}",
                    model, s.DailyUsage.PromptTokens, s.DailyUsage.CompletionTokens, s.DailyUsage.TotalTokens);
            }
        }
    }

    public static bool IsDailyLimitReached(string model)
    {
        lock (Sync)
        {
            if (!StatisticsByModel.TryGetValue(model, out var s) || !Limits.TryGetValue(model, out var limit))
            {
                return false;
            }

            return limit.MaxTokenCount &gt; 0 &amp;&amp; s.DailyUsage.TotalTokens &gt; limit.MaxTokenCount;
        }
    }

    private static void SaveStatistics()
    {
        string json = JsonConvert.SerializeObject(StatisticsByModel);
        using var scope = AlvaoContext.GetConnectionScope();
        scope.Connection.Execute(@"spUpdateInsertProperty", new
        {
           PropertyName,
           sPropertyValue = json,
           bPropertyValue = (bool?)null,
           iPropertyValue = (int?)null,
           dPropertyValue = (DateTime?)null
        },
           transaction: scope.Transaction,
           commandType: System.Data.CommandType.StoredProcedure);
    }

    private static T Load&lt;T&gt;(string propertyName) where T : new()
    {
        var sql = "select sPropertyValue from tProperty where sProperty = @propertyName";
        using var scope = AlvaoContext.GetConnectionScope();
        string json = scope.Connection.QueryFirstOrDefault&lt;string&gt;(sql, new { propertyName }, transaction: scope.Transaction);
        if (string.IsNullOrEmpty(json)) return new T();

        try
        {
           return JsonConvert.DeserializeObject&lt;T&gt;(json);
        }
        catch
        {
           return new T();
        }
    }
}

public class TicketSummaryData
{
    public string ServiceName { get; set; }
    public string TicketTag { get; set; }
    public string TicketUrl { get; set; }
    public string TicketTitle { get; set; }
    public string TicketStatus { get; set; }
    public string Summary { get; set; }

    public TicketSummaryData() { }

    public TicketSummaryData(TicketSummary summary)
    {
        if (summary == null || summary.Ticket == null) return;
        ServiceName = summary.Ticket.ServiceName;
        TicketTag = summary.Ticket.Tag;
        TicketUrl = summary.Ticket.Url;
        TicketTitle = summary.Ticket.Title;
        TicketStatus = summary.Ticket.Status;
        Summary = summary.Summary;
    }
}

public class TicketSummary
{
    public TicketData Ticket { get; set; }
    public List&lt;tAct&gt; Acts { get; set; } = [];
    public NotifyPerson Person { get; set; }
    public string Conversation { get; set; }
    public string Summary { get; set; }
    public string ReasonForAttention { get; set; }
    public string Error { get; set; }

    public string GetAttentionNeededHtml()
    {
        return $@"&lt;li&gt;
&lt;a target=""_blank"" rel=""noopener noreferrer"" href=""{Ticket.Url}""&gt;{HttpUtility.HtmlEncode(Ticket.Tag)}&lt;/a&gt; -
{HttpUtility.HtmlEncode(ReasonForAttention)}
&lt;/li&gt;
";
    }

    public string GetHtml(bool showConversation, ITexts texts, DateTime startTime)
    {
        var html = new StringBuilder();
        int symbolNumber = Ticket.GetIconNumber(startTime);
        string ticketStatusSymbol = MailSettings.Symbols[symbolNumber];

        html.Append(
            $@"
&lt;div class=""article""&gt;
&lt;div class=""header""&gt;
    &lt;h2&gt;
        {ticketStatusSymbol}
        &lt;a target=""_blank"" rel=""noopener noreferrer"" href=""{Ticket.Url}""&gt;{HttpUtility.HtmlEncode(Ticket.Tag)}&lt;/a&gt;
        - {HttpUtility.HtmlEncode(Ticket.Title)}
    &lt;/h2&gt;
    &lt;span&gt;
        &lt;b&gt;{texts.Requester}:&lt;/b&gt;
        {HttpUtility.HtmlEncode(Ticket.Requester)}
    &lt;/span&gt;
    &lt;span&gt;
        &lt;b&gt;{texts.Service}:&lt;/b&gt;
        {HttpUtility.HtmlEncode(Ticket.ServiceName)}
    &lt;/span&gt;
    &lt;span&gt;
        &lt;b&gt;{texts.Solver}:&lt;/b&gt;
        {HttpUtility.HtmlEncode(Ticket.Solver)}
    &lt;/span&gt;
    &lt;span&gt;
        &lt;b&gt;{texts.Status}:&lt;/b&gt;
        {HttpUtility.HtmlEncode(Ticket.Status)}
    &lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;{HttpUtility.HtmlEncode(Summary)}&lt;/p&gt;
");
        if (!string.IsNullOrEmpty(Error))
        {
            html.AppendLine($@"&lt;p style=""color: red;""&gt;Error: {HttpUtility.HtmlEncode(Error)}&lt;/p&gt;");
        }

        if (showConversation)
        {
            html.Append($@"
&lt;h4&gt;Conversation:&lt;/h4&gt;
&lt;pre style=""white-space: pre-wrap; word-break: break-word;""&gt;");
            foreach (var message in Ticket.Messages)
            {
                html.AppendLine();
                html.AppendLine($"Type: {HttpUtility.HtmlEncode(message.Type)}");
                html.AppendLine($"From: {HttpUtility.HtmlEncode(message.From)}");
                html.AppendLine($"To: {HttpUtility.HtmlEncode(message.To)}");
                html.AppendLine($"Title: {HttpUtility.HtmlEncode(message.Title)}");
                html.AppendLine();
                html.AppendLine(HttpUtility.HtmlEncode(message.Text));
                html.AppendLine(new string('-', 80));
            }
            html.Append("&lt;/pre&gt;");
        }
        html.AppendLine("&lt;/div&gt;");

        return html.ToString();
    }
}

public class TicketSummaryResponse
{
    [JsonProperty("summary")]
    public string Summary { get; set; }
    [JsonProperty("error")]
    public string Error { get; set; }
}

public class TotalTicketSummaryResponse
{
    [JsonProperty("tickets")]
    public List&lt;TicketResponse&gt; Tickets { get; set; }
}

public class TicketResponse
{
    [JsonProperty("id")]
    public int Id { get; set; }
    [JsonProperty("summary")]
    public string Summary { get; set; }
    [JsonProperty("reasonForAttention")]
    public string ReasonForAttention { get; set; }
}

public class NotifyPerson
{
    public tPerson Person { get; init; }
    public CultureInfo Culture { get; init; }
    public List&lt;TicketSummary&gt; Summaries { get; set; } = [];
    public string TotalSummary { get; set; }
    public string AllTicketSummary { get; set; }
}

public class MessageData
{
    public string From { get; set; }
    public string To { get; set; }
    public string Title { get; set; }
    public string Text { get; set; }
    public string Type { get; set; }
    public bool IsImportant { get; set; }
    public bool IsSolution { get; set; }
    public bool IsSentByCurrentUser { get; set; }
}

public class TicketData
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Status { get; set; }
    public string ServiceName { get; set; }
    public List&lt;MessageData&gt; Messages { get; } = [];

    public int GetIconNumber(DateTime time)
    {
        if (ClosedDate != null) return 2; // resolved
        if (Created &lt; time) return 1; // updated
        return 0; // new
    }

    [JsonIgnore]
    public string Url { get; set; }
    [JsonIgnore]
    public string Tag { get; set; }
    [JsonIgnore]
    public int StartingActId { get; set; }
    [JsonIgnore]
    public DateTime Created { get; set; }
    [JsonIgnore]
    public DateTime? Resolved { get; set; }
    [JsonIgnore]
    public DateTime? ClosedDate { get; set; }
    [JsonIgnore]
    public string Requester { get; set; }
    [JsonIgnore]
    public string Solver { get; set; }
    [JsonIgnore]
    public string SolverGroup { get; set; }
}

public interface ITexts
{
    string Footer { get; }
    string Greeting { get; }
    string Subject { get; }
    string AttentionNeeded { get; }
    string TicketUpdates { get; }
    string Introduction { get; }
    string Service { get; }
    string Solver { get; }
    string Status { get; }
    string Requester { get; }
}

public class EnglishTexts : ITexts
{
    public string Subject =&gt; "Service desk daily summary - {0}";
    public string Greeting =&gt; @"Hello";
    public string Introduction =&gt; @"Here is your daily summary of Service Desk activity for {0}.";
    public string TicketUpdates =&gt; "Ticket updates";
    public string Footer =&gt; @"
Note:
This summary was generated using AI tools to assist with daily reporting.
Please verify details in the Service Desk system if needed.
This is an automated message.
Please do not reply.
";
    public string AttentionNeeded =&gt; "Tickets needing attention";
    public string Service =&gt; "Service";
    public string Solver =&gt; "Solver";
    public string Status =&gt; "Status";
    public string Requester =&gt; "Requester";
}

public class CzechTexts : ITexts
{
    public string Subject =&gt; "Denní souhrn ze Service Desku  - {0}";
    public string Greeting =&gt; @"Dobrý den!";
    public string Introduction =&gt; @"Zde je váš denní přehled aktivit ze Servis Desku pro {0}.";
    public string TicketUpdates =&gt; "Změny v tiketech";
    public string Footer =&gt; @"
Poznámka:
Tento souhrn byl vygenerován pomocí nástrojů umělé inteligence, které pomáhají s denním reportováním.
V případě potřeby si prosím ověřte podrobnosti v systému Service Desk.
Toto je automatická zpráva.
Prosím, neodpovídejte.
";
    public string AttentionNeeded =&gt; "Tikety vyžadující pozornost";
    public string Service =&gt; "Služba";
    public string Solver =&gt; "Řešitel";
    public string Status =&gt; "Stav";
    public string Requester =&gt; "Žadatel";
}

public static class MailSettings
{
    public const string NewSymbol = "🆕";
    public const string UpdatedSymbol = "🔄";
    public const string ResolvedSymbol = "✅";
    public static List&lt;string&gt; Symbols =&gt; [ NewSymbol, UpdatedSymbol, ResolvedSymbol ];
    public const string Css = @"
:root {
    --color-bg: #ffffff;
    --color-surface: #ffffff;
    --color-text-primary: #333333;
    --color-text-secondary: #50504f;
    --color-border: #dddddd;
    --color-shadow-rgb: 0,0,0;

    --color-link: #115ea3;
    --color-link-hover: #0f548c;
    --color-link-active: #0c3b5e;
    --color-link-visited: #5c2e91;

    --radius-md: 8px;
    --space-xs: 5px;
    --space-sm: 7px;
    --space-md: 10px;
    --font-family-base: 'Segoe UI', 'Segoe UI Web (West European)', 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif;
    --font-size-h1: 21px;
    --font-size-h2: 16px;
    --font-size-text: 14px;
    --font-size-meta: 12px;
    --line-height-h2: 22px;
}

@media (prefers-color-scheme: dark) {
    :root {
        --color-bg: #1e1e1e;
        --color-surface: #2a2a2a;
        --color-text-primary: #f3f3f3;
        --color-text-secondary: #b5b5b5;
        --color-border: #3d3d3d;
        --color-shadow-rgb: 0,0,0;

        --color-link: #479ef5;
        --color-link-hover: #62abf5;
        --color-link-active: #2899f5;
        --color-link-visited: #b4a0ff;
    }
}

body {
    font-family: var(--font-family-base);
    background: #ffffff;
    background: var(--color-bg);
    color: #333333;
    color: var(--color-text-primary);
    margin: 0;
    padding: 0;
    -webkit-text-size-adjust: 100%;
}

div.section {
    background: #ffffff;
    background: var(--color-surface);
    box-shadow: 0 3px 9px rgba(var(--color-shadow-rgb), 0.175);
    border-radius: var(--radius-md);
    min-width: 300px;
    margin: var(--space-md);
    padding: var(--space-md);
}

h1 {
    color: #333333;
    color: var(--color-text-primary);
    font-size: var(--font-size-h1);
    margin: 0 0 var(--space-md) 0;
    font-weight: normal;
}

h2 {
    color: #333333;
    color: var(--color-text-primary);
    font-size: var(--font-size-h2);
    font-weight: 600;
    line-height: var(--line-height-h2);
    margin: var(--space-xs) 0;
}

div.article {
    border-top: 1px solid #dddddd;
    border-top: 1px solid var(--color-border);
    padding: 0;
    margin: 0 0 var(--space-md) 0;
}

div.article:last-child {
    margin-bottom: 0;
}

div.header span {
    margin-right: var(--space-sm);
    font-size: var(--font-size-meta);
    white-space: nowrap;
    color: #50504f;
    color: var(--color-text-secondary);
}

p {
    margin: var(--space-md);
    font-size: var(--font-size-text);
    color: #333333;
    color: var(--color-text-primary);
}

div.article p {
    margin: var(--space-sm) 0;
}

li {
    font-size: var(--font-size-text);
    color: #333333;
    color: var(--color-text-primary);
}

a {
    color: #115ea3;
    color: var(--color-link);
    text-decoration: none;
}
a:hover,
a:focus {
    color: #0f548c;
    color: var(--color-link-hover);
    text-decoration: underline;
}
a:active {
    color: #0c3b5e;
    color: var(--color-link-active);
}
a:visited {
    color: #5c2e91;
    color: var(--color-link-visited);
}

.link-underline {
    text-decoration: underline !important;
}

div.footer {
    margin: var(--space-md);
    font-size: var(--font-size-text);
    font-style: italic;
    color: #333333;
    color: var(--color-text-primary);
    border-top: 1px solid var(--color-border);
}

[data-ogsc] body,
[data-ogsc] .darkmode-bg {
    background: #1e1e1e !important;
    color: #f3f3f3 !important;
}

[data-ogsc] div.section {
    background: #2a2a2a !important;
}

[data-ogsc] h1,
[data-ogsc] h2,
[data-ogsc] p {
    color: #f3f3f3 !important;
}

[data-ogsc] div.header span {
    color: #b5b5b5 !important;
}

[data-ogsc] div.article {
    border-top-color: #3d3d3d !important;
}

[data-ogsc] a {
    color: #479ef5 !important;
}
[data-ogsc] a:hover,
[data-ogsc] a:focus {
    color: #62abf5 !important;
    text-decoration: underline !important;
}
[data-ogsc] a:active {
    color: #2899f5 !important;
}
[data-ogsc] a:visited {
    color: #b4a0ff !important;
}

/* Utility if you must force dark surface somewhere */
.darkmode-bg {
    background: #2a2a2a;
    background: var(--color-surface);
}
";
}</Code>
          <IsLibCode>true</IsLibCode>
          <Codesign>jrE1mU79fzSNkbYlN57stXOzSSSiK3mLvCMSi4eUd8WeBcqE8J+EHgExu7vH5UgxESpBp0QcdK2DYBez++zEcB2qYDv22zz0dI5m/JD///L527y44voSox5hlSnGNhE5dRpdlBC8Jy/95CoSPwS4fCZoV1EExmXbMjFNqkQ1JrlUu2UB1H2gq+8YR47GOlYCXTodCAVuh2u5Vr73yCAAvjlfzAKIDvN0lQRU5kkzFrK5CInjApPoKH16DmgsedqYtMJv2Jj/2hIOlNyV3TgEzHK/RO8icuPWlbFjAojDDbt45g15nMWswd/KlcCHIkTUmmXXFPwMInxZ9viBH2mJig==</Codesign>
        </Script>
      </Scripts>
    </Application>
  </Applications>
</AlvaoApplication>