803 lines
54 KiB
C#
803 lines
54 KiB
C#
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<AiTranslateResponse> 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<ChatMessage>
|
||
{
|
||
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<Dictionary<string, string>>(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<string, (string culture, DateWords words)> 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<AiChatResponse> ChatAsync(AiChatRequest request)
|
||
{
|
||
var messages = new List<ChatMessage>();
|
||
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<AiCardDTO>? cards = null;
|
||
|
||
var tools = new List<AITool>
|
||
{
|
||
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<ManagerService.Data.SubSection.SectionArticle>()
|
||
.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<ManagerService.Data.SubSection.SectionAgenda>()
|
||
.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<List<RemoteEventAgendaDTO>>(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<ManagerService.Data.SubSection.SectionMap>()
|
||
.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<SectionAgenda>().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<List<RemoteEventAgendaDTO>>(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<ManagerService.Data.SubSection.SectionMap>()
|
||
.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<AITool>
|
||
{
|
||
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<ManagerService.Data.SubSection.SectionAgenda>()
|
||
.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<List<RemoteEventAgendaDTO>>(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<ManagerService.Data.SubSection.SectionMap>()
|
||
.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<SectionAgenda>().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<List<RemoteEventAgendaDTO>>(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<ManagerService.Data.SubSection.SectionMap>()
|
||
.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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|