using ManagerService.Data; using ManagerService.DTOs; using Microsoft.Extensions.AI; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using System; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Manager.DTOs; using ManagerService.Data.SubSection; namespace ManagerService.Services { public class AssistantService : IAssistantService { private readonly IChatClient _chatClient; private readonly MyInfoMateDbContext _context; private const int MaxHistoryMessages = 10; public AssistantService(IChatClient chatClient, MyInfoMateDbContext context) { _chatClient = chatClient; _context = context; } public async Task TranslateAsync(AiTranslateRequest request) { var targetList = string.Join(", ", request.TargetLangs); var exampleJson = "{" + string.Join(", ", request.TargetLangs.Select(l => $"\"{l}\": \"...\"")) + "}"; var prompt = $""" Tu es un traducteur professionnel. Les codes de langue utilisés sont : FR=français, NL=néerlandais, EN=anglais, DE=allemand, IT=italien, ES=espagnol, PL=polonais, CN=chinois, AR=arabe, UK=ukrainien. Traduis le texte HTML suivant de la langue "{request.SourceLang}" vers ces langues : {targetList}. Conserve exactement le formatage HTML (balises, attributs, structure). Ne traduis pas le contenu des attributs HTML. Réponds UNIQUEMENT avec un objet JSON valide, sans markdown, sans explication, au format : {exampleJson} Texte à traduire : {request.Text} """; var messages = new List { new ChatMessage(ChatRole.User, prompt) }; var response = await _chatClient.GetResponseAsync(messages); var json = response.Text?.Trim() ?? "{}"; // Nettoyer les éventuels blocs markdown ```json ... ``` if (json.StartsWith("```")) { json = System.Text.RegularExpressions.Regex.Replace(json, @"```[a-z]*\n?", "").Trim(); json = json.TrimEnd('`').Trim(); } var translations = Newtonsoft.Json.JsonConvert.DeserializeObject>(json) ?? new(); return new AiTranslateResponse { Translations = translations, TokensUsed = response.Usage?.TotalTokenCount ?? 0 }; } private static string StripHtml(string? html) => string.IsNullOrEmpty(html) ? "" : System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", "").Trim(); private record DateWords(string Today, string Tomorrow, string This, string From, string To, string The, string NoDate); private static readonly System.Collections.Generic.Dictionary LangConfig = new(StringComparer.OrdinalIgnoreCase) { ["FR"] = ("fr-FR", new("aujourd'hui", "demain", "ce", "du", "au", "le", "date non précisée")), ["NL"] = ("nl-BE", new("vandaag", "morgen", "deze", "van", "tot", "op", "datum niet bepaald")), ["EN"] = ("en-GB", new("today", "tomorrow", "this", "from", "to", "on", "date unknown")), ["DE"] = ("de-DE", new("heute", "morgen", "diesen", "vom", "bis", "am", "Datum unbekannt")), ["IT"] = ("it-IT", new("oggi", "domani", "questo", "dal", "al", "il", "data non precisata")), ["ES"] = ("es-ES", new("hoy", "mañana", "este", "del", "al", "el", "fecha no especificada")), ["PL"] = ("pl-PL", new("dzisiaj", "jutro", "w ten", "od", "do", "w", "data nieznana")), }; private static (System.Globalization.CultureInfo culture, DateWords words) GetLangConfig(string lang) { if (LangConfig.TryGetValue(lang ?? "FR", out var cfg)) return (new System.Globalization.CultureInfo(cfg.culture), cfg.words); return (new System.Globalization.CultureInfo("fr-FR"), LangConfig["FR"].words); } private static (string text, bool expectsReply) StripMarkdownForVoice(string text) { var expectsReply = true; if (text.Contains("[FIN]")) { expectsReply = false; text = text.Replace("[FIN]", "").TrimEnd(); } text = Regex.Replace(text, @"(?m)^\s*[-*•]\s+", ""); text = Regex.Replace(text, @"(?m)^\s*\d+\.\s+", ""); text = Regex.Replace(text, @"\*{1,2}([^*]+)\*{1,2}", "$1"); text = Regex.Replace(text, @"_{1,2}([^_]+)_{1,2}", "$1"); text = Regex.Replace(text, @"(?m)^#{1,6}\s+", ""); text = text.Replace("`", ""); text = Regex.Replace(text, @"\n{3,}", "\n\n").Trim(); return (text, expectsReply); } private static string FormatDateVoice(DateTime date, DateTime today, string lang) { var (culture, w) = GetLangConfig(lang); var diff = (date.Date - today.Date).Days; var dayName = culture.DateTimeFormat.GetDayName(date.DayOfWeek).ToLower(); var monthName = culture.DateTimeFormat.GetMonthName(date.Month).ToLower(); var yearSuffix = date.Year != today.Year ? $" {date.Year}" : ""; var absolute = $"{dayName} {date.Day} {monthName}{yearSuffix}"; return diff switch { 0 => $"{w.Today} {date.Day} {monthName}{yearSuffix}", 1 => $"{w.Tomorrow} {dayName} {date.Day} {monthName}{yearSuffix}", >= 2 and <= 7 => $"{w.This} {absolute}", _ => absolute, }; } private static string FormatDateRangeVoice(DateTime? from, DateTime? to, DateTime today, string lang) { var (_, w) = GetLangConfig(lang); if (from == null) return w.NoDate; if (to == null) return $"{w.The} {FormatDateVoice(from.Value, today, lang)}"; return $"{w.From} {FormatDateVoice(from.Value, today, lang)} {w.To} {FormatDateVoice(to.Value, today, lang)}"; } private static DateTime? ParseFilterDate(string? s) { if (string.IsNullOrEmpty(s)) return null; // Priorité au format ISO yyyy-MM-dd (language-neutral), puis fallbacks if (DateTime.TryParseExact(s, new[] { "yyyy-MM-dd", "dd/MM/yyyy", "MM/dd/yyyy", "dd-MM-yyyy" }, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dt1)) return dt1; if (DateTime.TryParse(s, out var dt2)) return dt2; return null; } private static (DateTime saturday, DateTime sunday) GetCurrentWeekend() { var now = DateTime.Now.Date; // Si on est dimanche, le weekend en cours a commencé hier if (now.DayOfWeek == DayOfWeek.Sunday) return (now.AddDays(-1), now); var daysToSat = ((int)DayOfWeek.Saturday - (int)now.DayOfWeek + 7) % 7; var sat = now.AddDays(daysToSat); return (sat, sat.AddDays(1)); } public async Task ChatAsync(AiChatRequest request) { var messages = new List(); ChatOptions options; var (weekendSat, weekendSun) = GetCurrentWeekend(); if (request.ConfigurationId != null) { // Scope configuration : l'IA connaît uniquement les sections de cette configuration var sections = _context.Sections.Where(s => s.ConfigurationId == request.ConfigurationId).ToList(); var config = _context.Configurations.FirstOrDefault(c => c.Id == request.ConfigurationId); var sectionsSummary = string.Join("\n", sections.Select(s => { var title = StripHtml(s.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? s.Title?.FirstOrDefault()?.value ?? s.Label); return $"- id:{s.Id} | type:{s.Type} | titre:\"{title}\""; })); var isVoiceMode = request.IsVoice; var systemPrompt = isVoiceMode ? $""" Tu es le guide audio de visite de "{config?.Label ?? "cette application"}". Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}. Le visiteur t'écoute via des lunettes connectées Ray-Ban — il ne voit PAS d'écran. Tu réponds en {request.Language}. Tu es chaleureux, naturel et très concis (2-3 phrases max). Voici les sections disponibles : {sectionsSummary} RÈGLES STRICTES pour le mode vocal — chaque violation rend la réponse inutilisable : 1. ZÉRO markdown : absolument aucun astérisque (*), tiret de liste (- item ou • item), dièse (#), backtick (`), ou crochet ([lien]). Écris uniquement des phrases. 2. Les dates dans les résultats des outils sont DÉJÀ formatées dans la langue de la réponse — recopie-les telles quelles, ne les reformate JAMAIS. 3. ZÉRO liste. Cite 2-3 éléments maximum directement dans une phrase fluide, séparés par des virgules. 4. Pour les événements → appelle "GetUpcomingEvents", puis cite exactement 2-3 des plus pertinents dans une seule phrase fluide. Même si l'outil en retourne 50, n'en cite que 2-3. Ajoute [FIN] à la fin. 5. Pour les détails d'un item → appelle "GetItemDetails" et résume en 2-3 phrases fluides. Ajoute [FIN] à la fin. 6. Pour les sections de type Article → appelle "GetSectionDetail", résume le contenu en 2-3 phrases, puis demande "Veux-tu en savoir plus ?" (sans ajouter [FIN] car tu attends une réponse). 7. Les sections de type Video, PDF, Slider, Quiz, Web, Game sont des contenus visuels. Si le visiteur en parle, mentionne leur existence mais précise qu'il doit les consulter sur l'écran. 8. NE propose JAMAIS de navigation — le visiteur ne peut pas toucher l'écran. 9. Ne mentionne JAMAIS les identifiants techniques (id, guid). 10. Chaque réponse doit tenir en moins de 20 secondes à l'oral. 11. Termine TOUJOURS par une phrase complète. 12. Tu réponds UNIQUEMENT aux questions liées à cette visite et au contenu disponible dans les sections ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle…), décline poliment en une phrase courte et naturelle — varie la formulation à chaque fois (ex: "Ma spécialité c'est cette visite, pas ce sujet !", "Je suis ton guide ici, pas un moteur de recherche !", "Bonne question, mais là je sèche — demande-moi plutôt ce qu'il y a à voir ici !") — et ajoute [FIN]. 13. Politesses et fin de conversation ("merci", "au revoir", "c'est tout", "ok merci") → réponds chaleureusement en une phrase courte ("Bonne visite !" ou "Avec plaisir, bonne visite !") et ajoute [FIN]. 14. Signal [FIN] : ajoute le token [FIN] à la toute fin quand ta réponse est une information pure sans question posée au visiteur. Ne l'ajoute PAS quand tu poses une question (article, proposition de détails). """ : $""" Tu es l'assistant de visite de l'application "{config?.Label ?? "cette application"}". Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}. Le weekend en cours (ou prochain) : samedi {weekendSat:dd/MM/yyyy} – dimanche {weekendSun:dd/MM/yyyy}. Ton but est d'accompagner le visiteur dans son expérience. Tu réponds en {request.Language}. Tu es chaleureux, concis et proactif. Voici les sections disponibles : {sectionsSummary} RÈGLES STRICTES — tu dois les respecter sans exception : 1. Tu ne réponds JAMAIS "je ne peux pas" ou "je n'ai pas accès" si un outil existe pour répondre. Tu appelles l'outil, tu reçois les données, TU les présentes. 2. Toute question sur des événements, le planning, les dates, les activités → appelle "GetUpcomingEvents" IMMÉDIATEMENT. Si l'utilisateur précise une période, calcule les dates et passe-les en paramètres dateFrom/dateTo au format ISO yyyy-MM-dd. Exemples : "ce weekend" → dateFrom={weekendSat:yyyy-MM-dd}&dateTo={weekendSun:yyyy-MM-dd} ; "la semaine prochaine" → lundi au dimanche suivant. 3. Pour présenter une liste d'événements, appelle TOUJOURS "show_cards" avec les titres et dates — ne liste jamais les événements en texte brut. 4. Après avoir présenté des résultats d'événements, appelle TOUJOURS "navigate_to_section" avec l'ID de la section agenda trouvée dans la liste ci-dessus. 5. Si l'utilisateur veut voir les activités ou lieux → utilise "GetMapPoints". 6. Pour les détails d'un item spécifique → utilise "GetItemDetails". - Ne mentionne JAMAIS les identifiants techniques (id, guid) dans tes réponses finales. - NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats. - NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide. """; messages.Add(new ChatMessage(ChatRole.System, systemPrompt)); foreach (var h in request.History.TakeLast(MaxHistoryMessages)) messages.Add(new ChatMessage(h.Role == "user" ? ChatRole.User : ChatRole.Assistant, h.Content)); messages.Add(new ChatMessage(ChatRole.User, request.Message)); NavigationActionDTO? navigation = null; List? cards = null; var tools = new List { AIFunctionFactory.Create( (string sectionId) => { var section = _context.Sections .FirstOrDefault(s => s.Id == sectionId && s.ConfigurationId == request.ConfigurationId); if (section == null) return "Section non trouvée."; var lang = request.Language; var title = StripHtml(section.Title?.FirstOrDefault(t => t.language == lang)?.value ?? section.Title?.FirstOrDefault()?.value ?? section.Label); var desc = StripHtml(section.Description?.FirstOrDefault(t => t.language == lang)?.value ?? section.Description?.FirstOrDefault()?.value ?? ""); // Requêtes explicites par type pour contourner les problèmes de cache EF Core var extra = ""; var asArticle = _context.Sections .OfType() .FirstOrDefault(s => s.Id == sectionId); if (asArticle != null) { extra = StripHtml(asArticle.ArticleContent?.FirstOrDefault(t => t.language == lang)?.value ?? asArticle.ArticleContent?.FirstOrDefault()?.value ?? ""); } var sb = new System.Text.StringBuilder(); sb.AppendLine($"Titre: {title}"); if (!string.IsNullOrWhiteSpace(desc)) sb.AppendLine($"Description: {desc}"); if (!string.IsNullOrWhiteSpace(extra)) sb.AppendLine($"Contenu: {extra}"); sb.AppendLine($"Type: {section.Type}"); return sb.ToString().Trim(); }, "GetSectionDetail", "Récupère le titre, la description et le contenu complet d'une section (article, vidéo, etc.)." ), AIFunctionFactory.Create( async (string? dateFrom, string? dateTo) => { var today = DateTime.UtcNow.Date; var horizon = today.AddMonths(3); var filterFrom = ParseFilterDate(dateFrom); var filterTo = ParseFilterDate(dateTo); var agendaSections = _context.Sections .OfType() .Where(s => s.ConfigurationId == request.ConfigurationId) .ToList(); var allEvents = new List<(DateTime sort, string line)>(); foreach (var agenda in agendaSections) { try { if (agenda.IsOnlineAgenda) { var resourceId = agenda.AgendaResourceIds?.FirstOrDefault(r => r.language == request.Language)?.value ?? agenda.AgendaResourceIds?.FirstOrDefault()?.value; if (resourceId != null) { var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId); if (resource?.Url != null) { using var client = new System.Net.Http.HttpClient(); var json = await client.GetStringAsync(resource.Url); var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject>(json, RemoteAgendaJsonSettings.Lenient); if (remoteEvents != null) { foreach (var e in remoteEvents) { var dtFrom = e.GetDateFrom(); var dtTo = e.GetDateTo(); var end = dtTo ?? dtFrom; if (end == null || end.Value.Date < today || dtFrom?.Date > horizon) continue; if (filterFrom.HasValue && end.Value.Date < filterFrom.Value) continue; if (filterTo.HasValue && dtFrom?.Date > filterTo.Value) continue; var dtToOpt = dtTo.HasValue && dtTo.Value.Date != dtFrom?.Date ? dtTo : null; var dateStr = isVoiceMode ? FormatDateRangeVoice(dtFrom, dtToOpt, today, request.Language) : (dtFrom.HasValue ? dtFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée") + (dtToOpt.HasValue ? $" → {dtToOpt.Value:dd/MM/yyyy}" : ""); allEvents.Add((dtFrom ?? DateTime.MaxValue, isVoiceMode ? $"{e.name ?? "Sans titre"} ({dateStr})" : $"- [En ligne] AgendaId:{agenda.Id} | Titre:{e.name ?? "Sans titre"} | Date:{dateStr}")); } } } } } else { var qFrom = filterFrom ?? today; var qTo = filterTo ?? horizon; var events = _context.EventAgendas .Where(e => e.SectionAgendaId == agenda.Id && (e.DateTo ?? e.DateFrom) >= qFrom && e.DateFrom <= qTo) .OrderBy(e => e.DateFrom) .ToList(); foreach (var e in events) { var title = e.Label?.FirstOrDefault(t => t.language == request.Language)?.value ?? e.Label?.FirstOrDefault()?.value ?? "Sans titre"; var dtToOpt2 = e.DateTo.HasValue && e.DateTo.Value.Date != e.DateFrom?.Date ? e.DateTo : null; var dateStr = isVoiceMode ? FormatDateRangeVoice(e.DateFrom, dtToOpt2, today, request.Language) : (e.DateFrom.HasValue ? e.DateFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée") + (dtToOpt2.HasValue ? $" → {dtToOpt2.Value:dd/MM/yyyy}" : ""); allEvents.Add((e.DateFrom ?? DateTime.MaxValue, isVoiceMode ? $"{title} ({dateStr})" : $"- SectionId:{agenda.Id} | EventId:{e.Id} | Titre:{title} | Date:{dateStr}")); } } } catch (Exception ex) { allEvents.Add((DateTime.MaxValue, $"- [ERREUR agenda {agenda.Label}] {ex.Message}")); } } if (!allEvents.Any()) { var period = filterFrom.HasValue ? $" du {filterFrom.Value:dd/MM/yyyy} au {(filterTo ?? filterFrom.Value):dd/MM/yyyy}" : " dans les 3 prochains mois"; return $"Aucun événement à venir{period}."; } var lines = allEvents.OrderBy(x => x.sort).Take(50).Select(x => x.line); return string.Join("\n", lines); }, "GetUpcomingEvents", "Retourne les événements à venir (max 50, triés par date). Paramètres optionnels dateFrom et dateTo au format dd/MM/yyyy pour filtrer côté serveur (ex: ce weekend → dateFrom=21/03/2026&dateTo=22/03/2026, la semaine prochaine → lundi au dimanche suivant)." ), AIFunctionFactory.Create( () => { var points = _context.Sections .OfType() .Where(s => s.ConfigurationId == request.ConfigurationId) .SelectMany(s => s.MapPoints) .ToList(); if (!points.Any()) return "Aucun point d'intérêt trouvé."; return string.Join("\n", points.Select(p => { var title = p.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? p.Title?.FirstOrDefault()?.value ?? "Sans titre"; return $"- ID:{p.Id} | Titre:{title}"; })); }, "GetMapPoints", "Liste tous les points d'intérêt (musées, monuments, activités) disponibles sur la carte." ), AIFunctionFactory.Create( async (string type, string id) => { if (id.StartsWith("remote:")) { // Format: remote:[agendaId]:[eventId] var parts = id.Split(':'); if (parts.Length < 3) return "Format d'ID distant invalide."; var agendaId = parts[1]; var eventId = parts[2]; var agenda = _context.Sections.OfType().FirstOrDefault(s => s.Id == agendaId); if (agenda == null) return "Agenda non trouvé."; var resourceId = agenda.AgendaResourceIds?.FirstOrDefault(r => r.language == request.Language)?.value ?? agenda.AgendaResourceIds?.FirstOrDefault()?.value; if (resourceId == null) return "Ressource de l'agenda non trouvée."; var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId); if (resource?.Url == null) return "URL de l'agenda non trouvée."; try { using var client = new System.Net.Http.HttpClient(); var json = await client.GetStringAsync(resource.Url); var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject>(json, RemoteAgendaJsonSettings.Lenient); var e = remoteEvents?.FirstOrDefault(ev => ev.name == eventId); if (e == null) return "Événement distant non trouvé."; var title = e.name ?? "Sans titre"; var desc = e.description ?? ""; var dtStart = e.GetDateFrom(); var dtEnd = e.GetDateTo(); var dateStr = dtStart.HasValue ? $"Du {dtStart.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; dateStr += dtEnd.HasValue ? $" au {dtEnd.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; return $"[ÉVÉNEMENT (EN LIGNE)]\nTitre: {title}\nDate: {dateStr}\nDescription: {desc}\nContact: {e.email} / {e.phone}\nSite: {e.website}"; } catch (Exception ex) { return $"Erreur lors de la récupération des détails : {ex.Message}"; } } if (type.ToLower() == "event") { int.TryParse(id, out var intId); var e = _context.EventAgendas.FirstOrDefault(ev => ev.Id == intId); if (e == null) return "Événement non trouvé."; var title = e.Label?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Sans titre"; var desc = e.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? ""; var dateStr = e.DateFrom.HasValue ? $"Du {e.DateFrom.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; dateStr += e.DateTo.HasValue ? $" au {e.DateTo.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; return $"[ÉVÉNEMENT]\nTitre: {title}\nDate: {dateStr}\nDescription: {desc}\nContact: {e.Email} / {e.Phone}\nSite: {e.Website}"; } else { int.TryParse(id, out var intId); var p = _context.Sections.OfType() .SelectMany(s => s.MapPoints) .FirstOrDefault(pt => pt.Id == intId); if (p == null) return "Point d'intérêt non trouvé."; var title = p.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Sans titre"; var desc = p.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? ""; var prices = p.Prices?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Non spécifié"; var schedules = p.Schedules?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Non spécifié"; var contact = $"{p.Email?.FirstOrDefault()?.value} / {p.Phone?.FirstOrDefault()?.value}"; var site = p.Site?.FirstOrDefault()?.value; return $"[LIEU/ACTIVITÉ]\nTitre: {title}\nDescription: {desc}\nPrix: {prices}\nHoraires: {schedules}\nContact: {contact}\nSite: {site}"; } }, "GetItemDetails", "Récupère les détails complets (prix, horaires, contact, site) d'un événement (type='event') ou d'un point d'intérêt (type='poi') via son ID." ), }; // Navigation et cards : uniquement en mode UI (pas Voice) if (!isVoiceMode) { tools.Add(AIFunctionFactory.Create( (string sectionId, string sectionTitle, string sectionType) => { var imageUrl = sections.FirstOrDefault(s => s.Id == sectionId)?.ImageSource; navigation = new NavigationActionDTO { SectionId = sectionId, SectionTitle = sectionTitle, SectionType = sectionType, ImageUrl = imageUrl }; return Task.FromResult("Navigation proposée à l'utilisateur."); }, "navigate_to_section", "Propose navigation to a specific section when the user wants to go there or see it." )); tools.Add(AIFunctionFactory.Create( (string[] titles, string[] subtitles, string[]? icons) => { cards = titles.Select((t, i) => new AiCardDTO { Title = t, Subtitle = i < subtitles.Length ? subtitles[i] : "", Icon = icons != null && i < icons.Length ? icons[i] : null }).ToList(); return Task.FromResult("Cartes affichées."); }, "show_cards", "Display structured info cards in the chat. Use for lists of items (events, activities...) that benefit from visual presentation." )); } try { options = new ChatOptions { Tools = tools }; var response = await _chatClient.GetResponseAsync(messages, options); string reply; bool expectsReply; if (isVoiceMode) { (reply, expectsReply) = StripMarkdownForVoice(response.Text ?? ""); } else { reply = response.Text ?? ""; expectsReply = true; } return new AiChatResponse { Reply = reply, Cards = cards, Navigation = navigation, ExpectsReply = expectsReply, TokensUsed = response.Usage?.TotalTokenCount ?? 0 }; } catch (System.ClientModel.ClientResultException ex) { // Log plus de détails si possible sur l'erreur 400 de Google/OpenAI throw new System.Exception($"AI Service Error (Status: {ex.Status}): {ex.Message}", ex); } } else { // Scope instance : l'IA connaît les configurations disponibles pour cette application spécifiquement (AppType) var configurations = _context.AppConfigurationLinks .Where(l => l.ApplicationInstance.InstanceId == request.InstanceId && l.ApplicationInstance.AppType == request.AppType && l.IsActive) .Select(l => l.Configuration) .ToList(); var configIds = configurations.Select(c => c.Id).ToList(); // Récupère les types de sections par configuration pour aider l'IA à choisir var typesByConfig = _context.Sections .Where(s => configIds.Contains(s.ConfigurationId)) .Select(s => new { s.ConfigurationId, s.Type }) .ToList() .GroupBy(s => s.ConfigurationId) .ToDictionary(g => g.Key, g => g.Select(s => s.Type).Distinct().ToList()); var configsSummary = string.Join("\n", configurations.Select(c => { var title = StripHtml(c.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? c.Title?.FirstOrDefault()?.value ?? c.Label); typesByConfig.TryGetValue(c.Id, out var types); var typesStr = types != null && types.Any() ? $" | types disponibles: {string.Join(", ", types)}" : ""; return $"- id:{c.Id} | titre:\"{title}\"{typesStr}"; })); var isVoiceMode = request.IsVoice; var instanceSystemPrompt = isVoiceMode ? $""" Tu es le guide audio de visite. Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}. Le visiteur t'écoute via des lunettes connectées — il ne voit PAS d'écran. Tu réponds en {request.Language}. Tu es chaleureux, naturel et très concis (2-3 phrases max). Voici les expériences (visites) disponibles : {configsSummary} RÈGLES STRICTES pour le mode vocal : 1. ZÉRO markdown : aucun astérisque, tiret de liste, dièse, backtick. Écris uniquement des phrases. 2. ZÉRO liste. Cite 2-3 éléments maximum dans une phrase fluide séparés par des virgules. 3. Pour les événements → appelle "SearchEventsGlobal", puis cite exactement 2-3 des plus pertinents dans une phrase fluide. Ajoute [FIN]. 4. NE propose JAMAIS de navigation. Ne mentionne JAMAIS les identifiants techniques. 5. Chaque réponse doit tenir en moins de 20 secondes à l'oral. 6. Tu réponds UNIQUEMENT aux questions liées à cette visite. Pour toute question hors-sujet, réponds : "Je suis uniquement là pour t'accompagner dans ta visite." et ajoute [FIN]. 7. Politesses ("merci", "au revoir") → réponse courte et chaleureuse + [FIN]. 8. Signal [FIN] : ajoute-le quand ta réponse est une information pure sans question au visiteur. """ : $""" Tu es l'assistant de visite principal. Ton rôle est d'orienter l'utilisateur vers la bonne expérience de visite. Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}. Le weekend en cours (ou prochain) : samedi {weekendSat:dd/MM/yyyy} – dimanche {weekendSun:dd/MM/yyyy}. Tu réponds en {request.Language}. Tu es chaleureux, concis et proactif. Voici les expériences (configurations) disponibles : {configsSummary} RÈGLES STRICTES — tu dois les respecter sans exception : 1. Tu ne réponds JAMAIS "je ne peux pas" ou "je n'ai pas accès" si un outil existe pour répondre. Tu appelles l'outil, tu reçois les données, TU les présentes. 2. Toute question sur des événements, le planning, les dates → appelle "SearchEventsGlobal" IMMÉDIATEMENT. Si l'utilisateur précise une période, calcule les dates et passe-les en paramètres dateFrom/dateTo au format ISO yyyy-MM-dd. Exemples : "ce weekend" → dateFrom={weekendSat:yyyy-MM-dd}&dateTo={weekendSun:yyyy-MM-dd} ; "la semaine prochaine" → lundi au dimanche suivant. 3. Pour présenter une liste d'événements, appelle TOUJOURS "show_cards" avec les titres et dates — ne liste jamais les événements en texte brut. 4. Après avoir présenté des résultats d'événements, appelle TOUJOURS "navigate_to_configuration" avec l'ID de la visite concernée pour que l'utilisateur puisse y accéder directement. 5. Pour orienter vers une visite spécifique (jeu, quiz...) → "navigate_to_configuration". 6. Pour les détails d'un item → "GetItemDetailsGlobal". - NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats. - NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide. """; messages.Add(new ChatMessage(ChatRole.System, instanceSystemPrompt)); foreach (var h in request.History.TakeLast(MaxHistoryMessages)) messages.Add(new ChatMessage(h.Role == "user" ? ChatRole.User : ChatRole.Assistant, h.Content)); messages.Add(new ChatMessage(ChatRole.User, request.Message)); NavigationActionDTO? navigation = null; var tools = new List { AIFunctionFactory.Create( async (string? dateFrom, string? dateTo) => { var today = DateTime.UtcNow.Date; var horizon = today.AddMonths(3); var filterFrom = ParseFilterDate(dateFrom); var filterTo = ParseFilterDate(dateTo); var agendaSections = _context.Sections .OfType() .Where(s => configIds.Contains(s.ConfigurationId)) .ToList(); var allEvents = new List<(DateTime sort, string line)>(); foreach (var agenda in agendaSections) { try { if (agenda.IsOnlineAgenda) { var resourceId = agenda.AgendaResourceIds?.FirstOrDefault(r => r.language == request.Language)?.value ?? agenda.AgendaResourceIds?.FirstOrDefault()?.value; if (resourceId != null) { var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId); if (resource?.Url != null) { using var client = new System.Net.Http.HttpClient(); var json = await client.GetStringAsync(resource.Url); var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject>(json, RemoteAgendaJsonSettings.Lenient); if (remoteEvents != null) { var config = configurations.FirstOrDefault(c => c.Id == agenda.ConfigurationId); var configTitle = StripHtml(config?.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? config?.Label ?? "Inconnue"); foreach (var e in remoteEvents) { var dtFrom = e.GetDateFrom(); var dtTo = e.GetDateTo(); var end = dtTo ?? dtFrom; if (end == null || end.Value.Date < today || dtFrom?.Date > horizon) continue; if (filterFrom.HasValue && end.Value.Date < filterFrom.Value) continue; if (filterTo.HasValue && dtFrom?.Date > filterTo.Value) continue; var dateStr = dtFrom.HasValue ? dtFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée"; if (dtTo.HasValue && dtTo.Value.Date != dtFrom?.Date) dateStr += $" → {dtTo.Value.ToString("dd/MM/yyyy")}"; allEvents.Add((dtFrom ?? DateTime.MaxValue, $"- [Visite: {configTitle}] ConfigId:{agenda.ConfigurationId} | Titre:{e.name ?? "Sans titre"} | Date:{dateStr}")); } } } } } else { var qFrom = filterFrom ?? today; var qTo = filterTo ?? horizon; var events = _context.EventAgendas .Where(e => configIds.Contains(e.SectionAgenda.ConfigurationId) && (e.DateTo ?? e.DateFrom) >= qFrom && e.DateFrom <= qTo) .OrderBy(e => e.DateFrom) .ToList(); foreach (var e in events) { var config = configurations.FirstOrDefault(c => c.Id == e.SectionAgenda.ConfigurationId); var configTitle = StripHtml(config?.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? config?.Label ?? "Inconnue"); var title = e.Label?.FirstOrDefault(t => t.language == request.Language)?.value ?? e.Label?.FirstOrDefault()?.value ?? "Sans titre"; var dateStr = e.DateFrom.HasValue ? e.DateFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée"; if (e.DateTo.HasValue && e.DateTo.Value.Date != e.DateFrom?.Date) dateStr += $" → {e.DateTo.Value.ToString("dd/MM/yyyy")}"; allEvents.Add((e.DateFrom ?? DateTime.MaxValue, $"- [Visite: {configTitle}] ConfigId:{e.SectionAgenda.ConfigurationId} | EventId:{e.Id} | Titre:{title} | Date:{dateStr}")); } } } catch (Exception ex) { allEvents.Add((DateTime.MaxValue, $"- [ERREUR agenda {agenda.Label}] {ex.Message}")); } } if (!allEvents.Any()) { var period = filterFrom.HasValue ? $" du {filterFrom.Value:dd/MM/yyyy} au {(filterTo ?? filterFrom.Value):dd/MM/yyyy}" : " dans les 3 prochains mois"; return $"Aucun événement à venir{period}."; } var lines = allEvents.OrderBy(x => x.sort).Take(50).Select(x => x.line); return string.Join("\n", lines); }, "SearchEventsGlobal", "Retourne les événements à venir (max 50, triés par date). Paramètres optionnels dateFrom et dateTo au format dd/MM/yyyy pour filtrer côté serveur (ex: ce weekend → dateFrom=21/03/2026&dateTo=22/03/2026, la semaine prochaine → lundi au dimanche suivant)." ), AIFunctionFactory.Create( () => { var points = _context.Sections .OfType() .Where(s => configIds.Contains(s.ConfigurationId)) .SelectMany(s => s.MapPoints) .ToList(); if (!points.Any()) return "Aucun point d'intérêt trouvé."; return string.Join("\n", points.Select(p => { var title = p.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? p.Title?.FirstOrDefault()?.value ?? "Sans titre"; return $"- ID:{p.Id} | Titre:{title}"; })); }, "GetMapPointsGlobal", "Liste tous les points d'intérêt disponibles sur les cartes autorisées." ), AIFunctionFactory.Create( async (string type, string id) => { if (id.StartsWith("remote:")) { // Format: remote:[agendaId]:[eventId] var parts = id.Split(':'); if (parts.Length < 3) return "Format d'ID distant invalide."; var agendaId = parts[1]; var eventId = parts[2]; var agenda = _context.Sections.OfType().FirstOrDefault(s => s.Id == agendaId && configIds.Contains(s.ConfigurationId)); if (agenda == null) return "Agenda non trouvé ou non autorisé."; var resourceId = agenda.AgendaResourceIds?.FirstOrDefault(r => r.language == request.Language)?.value ?? agenda.AgendaResourceIds?.FirstOrDefault()?.value; if (resourceId == null) return "Ressource de l'agenda non trouvée."; var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId); if (resource?.Url == null) return "URL de l'agenda non trouvée."; try { using var client = new System.Net.Http.HttpClient(); var json = await client.GetStringAsync(resource.Url); var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject>(json, RemoteAgendaJsonSettings.Lenient); var e = remoteEvents?.FirstOrDefault(ev => ev.name == eventId); if (e == null) return "Événement distant non trouvé."; var title = e.name ?? "Sans titre"; var desc = e.description ?? ""; var dtStart = e.GetDateFrom(); var dtEnd = e.GetDateTo(); var dateStr = dtStart.HasValue ? $"Du {dtStart.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; dateStr += dtEnd.HasValue ? $" au {dtEnd.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; return $"[ÉVÉNEMENT (EN LIGNE)]\nTitre: {title}\nDate: {dateStr}\nDescription: {desc}\nContact: {e.email} / {e.phone}\nSite: {e.website}"; } catch (Exception ex) { return $"Erreur lors de la récupération des détails : {ex.Message}"; } } if (type.ToLower() == "event") { int.TryParse(id, out var intId); var e = _context.EventAgendas .Include(ev => ev.SectionAgenda) .FirstOrDefault(ev => ev.Id == intId && configIds.Contains(ev.SectionAgenda.ConfigurationId)); if (e == null) return "Événement non trouvé ou non autorisé."; var title = e.Label?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Sans titre"; var desc = e.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? ""; var dateStr = e.DateFrom.HasValue ? $"Du {e.DateFrom.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; dateStr += e.DateTo.HasValue ? $" au {e.DateTo.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; return $"[ÉVÉNEMENT]\nTitre: {title}\nDate: {dateStr}\nDescription: {desc}\nContact: {e.Email} / {e.Phone}\nSite: {e.Website}"; } else { int.TryParse(id, out var intId); var p = _context.Sections.OfType() .Where(s => configIds.Contains(s.ConfigurationId)) .SelectMany(s => s.MapPoints) .FirstOrDefault(pt => pt.Id == intId); if (p == null) return "Point d'intérêt non trouvé ou non autorisé."; var title = p.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Sans titre"; var desc = p.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? ""; var prices = p.Prices?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Non spécifié"; var schedules = p.Schedules?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Non spécifié"; var contact = $"{p.Email?.FirstOrDefault()?.value} / {p.Phone?.FirstOrDefault()?.value}"; var site = p.Site?.FirstOrDefault()?.value; return $"[LIEU/ACTIVITÉ]\nTitre: {title}\nDescription: {desc}\nPrix: {prices}\nHoraires: {schedules}\nContact: {contact}\nSite: {site}"; } }, "GetItemDetailsGlobal", "Détails d'un lieu ou événement au niveau instance." ), AIFunctionFactory.Create( (string configurationId, string configurationTitle) => { var imageUrl = configurations.FirstOrDefault(c => c.Id == configurationId)?.ImageSource; navigation = new NavigationActionDTO { SectionId = configurationId, SectionTitle = configurationTitle, SectionType = "Configuration", ImageUrl = imageUrl }; return Task.FromResult("Navigation vers la visite proposée."); }, "navigate_to_configuration", "Propose navigation to a specific visit/configuration when the user wants to start or explore it." ) }; try { options = new ChatOptions { Tools = tools }; var response = await _chatClient.GetResponseAsync(messages, options); string reply; bool expectsReply; if (isVoiceMode) { (reply, expectsReply) = StripMarkdownForVoice(response.Text ?? ""); } else { reply = response.Text ?? ""; expectsReply = true; } return new AiChatResponse { Reply = reply, Navigation = navigation, ExpectsReply = expectsReply, TokensUsed = response.Usage?.TotalTokenCount ?? 0 }; } catch (System.ClientModel.ClientResultException ex) { // Log plus de détails si possible sur l'erreur 400 de Google/OpenAI throw new System.Exception($"AI Service Error (Status: {ex.Status}): {ex.Message}", ex); } } } } }