From 9ba1902ee9dbac3b3c48d735b3bbcc3ba867dd2e Mon Sep 17 00:00:00 2001 From: Roy Ben Shabat Date: Sun, 7 Sep 2025 06:47:24 +0300 Subject: Added seesion, chat history on ADX... --- .../Web/Tango.Portal/Controllers/HomeController.cs | 8 +- .../Web/Tango.Portal/Models/SessionUser.cs | 4 + .../Visual_Studio_22/.claude/settings.local.json | 16 +++ .../Controllers/ChatController.cs | 108 ++++++++++++++++--- .../Controllers/HomeController.cs | 18 ++-- .../Models/ChatConversationMessage.cs | 17 +++ .../Tango.Portal.Chat.Web/Models/SessionUser.cs | 13 +++ .../Tango.Portal.Chat.Web/Program.cs | 3 + .../Services/ChatMessageLogger.cs | 116 +++++++++++++++++++++ .../Tango.Portal.Chat.Web/Utils/SessionUtils.cs | 36 +++++++ 10 files changed, 317 insertions(+), 22 deletions(-) create mode 100644 Software/Visual_Studio_22/Tango.Portal.Chat.Web/Models/ChatConversationMessage.cs create mode 100644 Software/Visual_Studio_22/Tango.Portal.Chat.Web/Models/SessionUser.cs create mode 100644 Software/Visual_Studio_22/Tango.Portal.Chat.Web/Services/ChatMessageLogger.cs create mode 100644 Software/Visual_Studio_22/Tango.Portal.Chat.Web/Utils/SessionUtils.cs (limited to 'Software') diff --git a/Software/Visual_Studio/Web/Tango.Portal/Controllers/HomeController.cs b/Software/Visual_Studio/Web/Tango.Portal/Controllers/HomeController.cs index dff8a140e..38eaaf4b3 100644 --- a/Software/Visual_Studio/Web/Tango.Portal/Controllers/HomeController.cs +++ b/Software/Visual_Studio/Web/Tango.Portal/Controllers/HomeController.cs @@ -171,8 +171,9 @@ namespace Tango.Portal.Controllers public ActionResult AI() { if (!SessionUser.IsTwineUser) return RedirectToAction(nameof(Login)); - String session = SimpleCryptoHelper.Encrypt(JsonConvert.SerializeObject(new { UserName = SessionUser.Name, Expires = DateTime.UtcNow.AddHours(1) })); - return new RedirectResult($"https://ai.twine-srv.com?session={session}"); + SessionUser.Expires = DateTime.UtcNow.AddHours(1); + String json = SimpleCryptoHelper.Encrypt(JsonConvert.SerializeObject(SessionUser)); + return new RedirectResult($"https://ai.twine-srv.com?session={json}"); } public ActionResult Docs() @@ -241,6 +242,9 @@ namespace Tango.Portal.Controllers SessionUser.IsAuthenticated = true; SessionUser.IsTwineUser = user.Organization.Name == "Twine"; SessionUser.Name = user.Contact.FirstName; + SessionUser.FullName = user.Contact.FullName; + SessionUser.Email = user.Email; + SessionUser.Organization = user.Organization.Name; return RedirectToAction(nameof(Index)); } diff --git a/Software/Visual_Studio/Web/Tango.Portal/Models/SessionUser.cs b/Software/Visual_Studio/Web/Tango.Portal/Models/SessionUser.cs index e38f11c77..83323b0ad 100644 --- a/Software/Visual_Studio/Web/Tango.Portal/Models/SessionUser.cs +++ b/Software/Visual_Studio/Web/Tango.Portal/Models/SessionUser.cs @@ -12,5 +12,9 @@ namespace Tango.Portal.Models public String Name { get; set; } public bool IsTwineUser { get; set; } + public string Email { get; set; } + public string Organization { get; set; } + public string FullName { get; set; } + public DateTime Expires { get; set; } } } \ No newline at end of file diff --git a/Software/Visual_Studio_22/.claude/settings.local.json b/Software/Visual_Studio_22/.claude/settings.local.json index eb6c45fa9..52f715339 100644 --- a/Software/Visual_Studio_22/.claude/settings.local.json +++ b/Software/Visual_Studio_22/.claude/settings.local.json @@ -11,6 +11,22 @@ "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Services/**)", "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Models/**)", "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Models/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Models/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Controllers/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Services/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Models/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Utils/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Models/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Controllers/**)", + "Bash(dotnet build:*)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Services/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Models/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Controllers/**)", + "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Services/**)", "Read(/C:\\DATA\\Development\\Tango\\Software\\Visual_Studio_22\\Tango.Portal.Chat.Web\\Models/**)" ], "deny": [], diff --git a/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Controllers/ChatController.cs b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Controllers/ChatController.cs index a13b6a47a..396651e3f 100644 --- a/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Controllers/ChatController.cs +++ b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Controllers/ChatController.cs @@ -19,13 +19,15 @@ namespace Tango.Portal.Chat.Web.Controllers private readonly KqlGuard _guard; private readonly KustoQueryService _adx; private readonly LlmClient _llm; + private readonly ChatMessageLogger _logger; - public ChatController(SchemaRegistry schema, KqlGuard guard, KustoQueryService adx, LlmClient llm) + public ChatController(SchemaRegistry schema, KqlGuard guard, KustoQueryService adx, LlmClient llm, ChatMessageLogger logger) { _schema = schema; _guard = guard; _adx = adx; _llm = llm; + _logger = logger; } [HttpPost("ask")] @@ -33,6 +35,36 @@ namespace Tango.Portal.Chat.Web.Controllers { try { + if (!SessionUtils.IsUserAuthenticated(HttpContext)) + { + return new ChatResponse + { + Answer = "User is not authenticated or session expired", + ThreadId = req.ThreadId + }; + } + + var sessionUser = SessionUtils.GetSessionUser(HttpContext); + var sessionId = HttpContext.Session.Id; + + // Log the question + _ = Task.Run(async () => + { + try + { + await _logger.LogQuestionAsync( + sessionId, + sessionUser?.Email ?? "unknown", + sessionUser?.FullName ?? "unknown", + req.Question, + ct); + } + catch + { + // Ignore logging failures + } + }, ct); + var schemaJson = _schema.GetSchemaJson(); var plannerPrompt = _schema.GetPlannerPrompt(); var plotySample = _schema.GetPlotySample(); @@ -50,30 +82,77 @@ namespace Tango.Portal.Chat.Web.Controllers plan = await _llm.ProposeKqlAsync(plannerPrompt, plotySample, req.Question, schemaJson, req.History, ct); } + ChatResponse response; if (plan.Assistant == "data" || plan.Assistant == "ploty") { - return await AnswerWithDataAssistant(req, plan, ct); + response = await AnswerWithDataAssistant(req, plan, ct); } else if (plan.Assistant == "docs") { - return await AnswerWithDocsAssistant(req, plan, ct); + response = await AnswerWithDocsAssistant(req, plan, ct); } else { - return AnswerWithPlannerConversation(req, plan); + response = AnswerWithPlannerConversation(req, plan); } + + // Log the answer + _ = Task.Run(async () => + { + try + { + await _logger.LogAnswerAsync( + sessionId, + sessionUser?.Email ?? "unknown", + sessionUser?.FullName ?? "unknown", + response, + plan.Assistant, + plan.Provider.ToString(), + ct); + } + catch + { + // Ignore logging failures + } + }, ct); + + return response; } catch (Exception ex) { - return new ChatResponse + var errorResponse = new ChatResponse { Answer = $"Ooops something went wrong...\n{ex.Message}", ThreadId = req.ThreadId }; + + // Log the error response + var sessionUser = SessionUtils.GetSessionUser(HttpContext); + var sessionId = req.ThreadId ?? Guid.NewGuid().ToString(); + _ = Task.Run(async () => + { + try + { + await _logger.LogAnswerAsync( + sessionId, + sessionUser?.Email ?? "unknown", + sessionUser?.FullName ?? "unknown", + errorResponse, + "error", + "Unknown", + ct); + } + catch + { + // Ignore logging failures + } + }, ct); + + return errorResponse; } } - private static ActionResult AnswerWithPlannerConversation(ChatRequest req, ProposeKqlResult plan) + private ChatResponse AnswerWithPlannerConversation(ChatRequest req, ProposeKqlResult plan) { return new ChatResponse { @@ -82,7 +161,7 @@ namespace Tango.Portal.Chat.Web.Controllers }; } - private async Task> AnswerWithDocsAssistant(ChatRequest req, ProposeKqlResult plan, CancellationToken ct) + private async Task AnswerWithDocsAssistant(ChatRequest req, ProposeKqlResult plan, CancellationToken ct) { // AFTER var run = await _llm.AnswerWithAssistantAsync( @@ -100,7 +179,7 @@ namespace Tango.Portal.Chat.Web.Controllers }; } - private async Task> AnswerWithDataAssistant(ChatRequest req, ProposeKqlResult plan, CancellationToken ct) + private async Task AnswerWithDataAssistant(ChatRequest req, ProposeKqlResult plan, CancellationToken ct) { // 2) Guardrail validation var val = _guard.Validate(plan.Kql); @@ -125,7 +204,12 @@ namespace Tango.Portal.Chat.Web.Controllers { if (plan.Provider == LlmProvider.OpenAI) { - return await Ask(req, ct, LlmProvider.Claude); + var fallbackResult = await Ask(req, ct, LlmProvider.Claude); + return fallbackResult.Value ?? new ChatResponse + { + Answer = "Fallback to Claude failed", + ThreadId = req.ThreadId + }; } else { @@ -156,7 +240,7 @@ namespace Tango.Portal.Chat.Web.Controllers } } - private async Task> AnswerWithDataAssistantInternal(ChatRequest req, ProposeKqlResult plan, DataTable table, CancellationToken ct) + private async Task AnswerWithDataAssistantInternal(ChatRequest req, ProposeKqlResult plan, DataTable table, CancellationToken ct) { var preview = DataHelper.ToPreview(table, 200); var facts = JsonSerializer.Serialize(preview); @@ -181,7 +265,7 @@ namespace Tango.Portal.Chat.Web.Controllers }; } - private static ActionResult AnswerWithMarkdownTable(ChatRequest req, ProposeKqlResult plan, DataTable table) + private ChatResponse AnswerWithMarkdownTable(ChatRequest req, ProposeKqlResult plan, DataTable table) { var markdown = DataHelper.ToMarkdownTable(table); @@ -194,7 +278,7 @@ namespace Tango.Portal.Chat.Web.Controllers }; } - private static ActionResult AnswerWithPloty(ChatRequest req, ProposeKqlResult plan, DataTable table) + private ChatResponse AnswerWithPloty(ChatRequest req, ProposeKqlResult plan, DataTable table) { String? ploty = table.Rows[0]["ploty"]?.ToString(); diff --git a/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Controllers/HomeController.cs b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Controllers/HomeController.cs index 6f1aa5dfa..8ba9fec32 100644 --- a/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Controllers/HomeController.cs +++ b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Controllers/HomeController.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Mvc; using System.Diagnostics; using System.Text.Json; +using Tango.Portal.Chat.Web.Models; +using Tango.Portal.Chat.Web.Utils; using Tango.Portal.Chat.Web.ViewModels; namespace Tango.Portal.Chat.Web.Controllers @@ -11,8 +13,9 @@ namespace Tango.Portal.Chat.Web.Controllers { if (Debugger.IsAttached) { + SessionUtils.SetSessionUser(HttpContext, new Models.SessionUser() { Name = "Debug User", Email = "roy@twine-s.com", FullName = "Debug User" }); HomeViewVM v = new HomeViewVM(); - v.UserName = "debug-user"; + v.UserName = "Debug User"; return View(v); } @@ -20,27 +23,26 @@ namespace Tango.Portal.Chat.Web.Controllers if (String.IsNullOrWhiteSpace(session)) return new RedirectResult(loginUrl); - String decryptedSession = String.Empty; + String json = String.Empty; try { - decryptedSession = SimpleCryptoHelper.Decrypt(session); + json = SimpleCryptoHelper.Decrypt(session); } catch { return new RedirectResult(loginUrl); } - var template = new { UserName = "", Expires = DateTime.MinValue }; - - var sessionUser = JsonSerializer.Deserialize(decryptedSession, template.GetType()); - if (sessionUser == null || (DateTime)(sessionUser as dynamic).Expires < DateTime.UtcNow) + var sessionUser = JsonSerializer.Deserialize(json); + if (sessionUser == null || sessionUser.Expires < DateTime.UtcNow) { return new RedirectResult(loginUrl); } HomeViewVM vm = new HomeViewVM(); - vm.UserName = (sessionUser as dynamic).UserName; + vm.UserName = sessionUser.Name; + SessionUtils.SetSessionUser(HttpContext, sessionUser); return View(vm); } } diff --git a/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Models/ChatConversationMessage.cs b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Models/ChatConversationMessage.cs new file mode 100644 index 000000000..3c056a922 --- /dev/null +++ b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Models/ChatConversationMessage.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Tango.Portal.Chat.Web.Models +{ + public class ChatConversationMessage + { + public string ID { get; set; } = Guid.NewGuid().ToString(); + public string SessionID { get; set; } = string.Empty; + public DateTime Time { get; set; } + public string Email { get; set; } = string.Empty; + public string User { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public string Classification { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Provider { get; set; } = string.Empty; + } +} diff --git a/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Models/SessionUser.cs b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Models/SessionUser.cs new file mode 100644 index 000000000..a9ce649f0 --- /dev/null +++ b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Models/SessionUser.cs @@ -0,0 +1,13 @@ +namespace Tango.Portal.Chat.Web.Models +{ + public class SessionUser + { + public bool IsAuthenticated { get; set; } + public String Name { get; set; } = String.Empty; + public bool IsTwineUser { get; set; } + public string Email { get; set; } = string.Empty; + public string Organization { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public DateTime Expires { get; set; } + } +} diff --git a/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Program.cs b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Program.cs index da82f82f5..41402c527 100644 --- a/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Program.cs +++ b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Program.cs @@ -12,8 +12,10 @@ builder.Services.Configure(builder.Configuration.GetSection("OpenAI" // ADX config builder.Services.Configure(builder.Configuration.GetSection("ADX")); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSession(); // Simple HTTP client for LLM builder.Services.AddHttpClient(); @@ -22,6 +24,7 @@ var app = builder.Build(); app.UseStaticFiles(); app.UseRouting(); +app.UseSession(); app.MapControllerRoute( name: "default", diff --git a/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Services/ChatMessageLogger.cs b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Services/ChatMessageLogger.cs new file mode 100644 index 000000000..4eed2182d --- /dev/null +++ b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Services/ChatMessageLogger.cs @@ -0,0 +1,116 @@ +using Azure.Core; +using Azure.Identity; +using Kusto.Data; +using Kusto.Data.Common; +using Kusto.Data.Net.Client; +using Microsoft.Extensions.Options; +using System.Text.Json; +using Tango.Portal.Chat.Web.Models; + +namespace Tango.Portal.Chat.Web.Services +{ + public sealed class ChatMessageLogger + { + private readonly ICslAdminProvider _admin; + private readonly string _database; + private readonly ILogger _logger; + + public ChatMessageLogger(IOptions opts, ILogger logger) + { + var options = opts.Value; + _database = options.Database; + _logger = logger; + + var cred = new ClientSecretCredential( + options.TenantId, + options.ClientId, + options.ClientSecret); + + var kcsb = new KustoConnectionStringBuilder(options.ClusterUri) + .WithAadAzureTokenCredentialsAuthentication(cred); + + _admin = KustoClientFactory.CreateCslAdminProvider(kcsb); + } + + public async Task LogMessageAsync(ChatConversationMessage message, CancellationToken ct = default) + { + try + { + var json = JsonSerializer.Serialize(message); + var command = $".ingest inline into table ChatConversationsTable with (jsonMappingReference = 'ChatConversationsTableMapping') <| {json}"; + + var props = new ClientRequestProperties + { + ClientRequestId = $"chat_log_{Guid.NewGuid()}" + }; + + using var result = await _admin.ExecuteControlCommandAsync(_database, command, props); + _logger.LogDebug("Successfully logged chat message {MessageId} for session {SessionId}", + message.ID, message.SessionID); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to log chat message {MessageId} for session {SessionId}", + message.ID, message.SessionID); + } + } + + public async Task LogQuestionAsync(string sessionId, string userEmail, string userName, string question, CancellationToken ct = default) + { + var message = new ChatConversationMessage + { + SessionID = sessionId, + Time = DateTime.UtcNow, + Email = userEmail, + User = userName, + Role = "user", + Classification = "question", + Message = question, + Provider = string.Empty + }; + + await LogMessageAsync(message, ct); + } + + public async Task LogAnswerAsync(string sessionId, string userEmail, string userName, ChatResponse response, string assistantType, string provider, CancellationToken ct = default) + { + var answerMessage = BuildAnswerMessage(response); + + var message = new ChatConversationMessage + { + SessionID = sessionId, + Time = DateTime.UtcNow, + Email = userEmail, + User = userName, + Role = "assistant", + Classification = assistantType, + Message = answerMessage, + Provider = provider + }; + + await LogMessageAsync(message, ct); + } + + private static string BuildAnswerMessage(ChatResponse response) + { + var parts = new List(); + + if (!string.IsNullOrEmpty(response.Answer)) + { + parts.Add($"Answer: {response.Answer}"); + } + + if (!string.IsNullOrEmpty(response.UsedKql)) + { + parts.Add($"Used KQL: {response.UsedKql}"); + } + + if (!string.IsNullOrEmpty(response.Ploty)) + { + parts.Add($"Visualization: {response.Ploty}"); + } + + return parts.Count > 0 ? string.Join("\n\n", parts) : response.Answer ?? string.Empty; + } + } +} \ No newline at end of file diff --git a/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Utils/SessionUtils.cs b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Utils/SessionUtils.cs new file mode 100644 index 000000000..91f118e0d --- /dev/null +++ b/Software/Visual_Studio_22/Tango.Portal.Chat.Web/Utils/SessionUtils.cs @@ -0,0 +1,36 @@ +using Tango.Portal.Chat.Web.Models; + +namespace Tango.Portal.Chat.Web.Utils +{ + public class SessionUtils + { + public static void SetSessionUser(HttpContext context, SessionUser user) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + if (user == null) throw new ArgumentNullException(nameof(user)); + + var json = System.Text.Json.JsonSerializer.Serialize(user); + context.Session.SetString("SessionUser", json); + } + + public static SessionUser? GetSessionUser(HttpContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + + var json = context.Session.GetString("SessionUser"); + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + return System.Text.Json.JsonSerializer.Deserialize(json); + } + + public static bool IsUserAuthenticated(HttpContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + var user = GetSessionUser(context); + return user != null; + } + } +} -- cgit v1.3.1