using Hangfire; using ManagerService.Data; using ManagerService.DTOs; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Logging; using NSwag.Annotations; using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace ManagerService.Controllers { [Authorize(Policy = ManagerService.Service.Security.Policies.Viewer)] [ApiController, Route("api/[controller]")] [OpenApiTag("AI", Description = "Assistant IA")] public class AiController : ControllerBase { /// /// Politique de limitation appliquée aux endpoints qui consomment des jetons. /// Déclarée ici parce que c'est le seul contrôleur concerné ; Startup la lit /// pour enregistrer le limiteur. /// public const string RateLimitPolicy = "ai"; /// /// Plafond IA cumulé sur toute la durée de l'essai gratuit (14 jours), distinct du /// compteur mensuel : ~30 requêtes * ~10k tokens/req estimés. Empêche qu'un essai à /// cheval sur deux mois calendaires obtienne deux fois le quota mensuel du plan. /// private const long TrialAiTokensCap = 300_000; private readonly IAssistantService _assistantService; private readonly MyInfoMateDbContext _context; private readonly ILogger _logger; // Injecté plutôt qu'appelé via la façade statique BackgroundJob : celle-ci lève // sans JobStorage.Current, donc dans tout test qui touche cet endpoint. private readonly IBackgroundJobClient _jobs; public AiController( IAssistantService assistantService, MyInfoMateDbContext context, ILogger logger, IBackgroundJobClient jobs) { _assistantService = assistantService; _context = context; _logger = logger; _jobs = jobs; } private string? GetCallerInstanceId() => User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value; private bool IsSuperAdmin() => User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin); /// /// Remet le compteur mensuel à zéro si le mois a changé, puis vérifie le quota mensuel /// du plan et, pendant l'essai gratuit, le plafond cumulé de l'essai. /// Retourne null si la requête peut passer, sinon la réponse d'erreur à renvoyer. /// private IActionResult? CheckQuota(Instance instance) { var monthKey = DateTime.UtcNow.ToString("yyyy-MM"); if (instance.AiUsageMonthKey != monthKey) { instance.AiTokensThisMonth = 0; instance.AiUsageMonthKey = monthKey; _context.SaveChanges(); } var quota = instance.AiTokensPerMonth; // 0 ne veut pas dire « illimité » ici — contrairement à StorageQuotaBytes, où 0 lève // la limite. C'est « pas d'IA dans ce plan » : plan-starter est à 0, et une instance // migrée depuis Mongo l'est aussi tant que son plan n'est pas repris (§1quinquies, c). // Sans ce test, une telle instance avec IsAssistant à true consommait sans compteur. if (quota <= 0) return StatusCode(403, "L'assistant IA n'est pas inclus dans ce plan"); if (instance.AiTokensThisMonth >= quota) return StatusCode(429, "Quota IA mensuel dépassé"); if (instance.IsTrialActive && instance.TrialAiTokensUsed >= TrialAiTokensCap) return StatusCode(429, "Quota IA de la période d'essai dépassé"); return null; } private void RecordUsage(Instance instance, long tokensUsed) { instance.AiTokensThisMonth += tokensUsed; if (instance.IsTrialActive) instance.TrialAiTokensUsed += tokensUsed; _context.SaveChanges(); } /// /// Journalise un tour de conversation. Ne fait jamais échouer la réponse au visiteur : /// un incident de journalisation ne doit pas coûter un échange déjà payé au modèle. /// /// /// ⚠️ **RGPD — le volet CGU doit être en ligne avant la mise en service.** Cette méthode /// enregistre du texte libre saisi par un visiteur, qui peut contenir des données /// personnelles voire sensibles (« je suis en fauteuil, c'est accessible ? »). Rien de /// nominatif n'est stocké — ConversationId est un GUID de session — et les /// questions brutes se purgent à 90 jours, mais l'information doit être donnée. /// /// HasAnswer se déduit des sources : le guide n'a rien trouvé quand la recherche /// n'a rien remonté. C'est la colonne qui produit le rapport de trous de contenu, donc /// l'argument de vente de tout l'onglet — la déduire du texte de la réponse serait /// fragile, un repli poli ressemblant à une vraie réponse. /// private void RecordVisitorQuestion(AiChatRequest request, AiChatResponse result) { try { var sources = result.Sources ?? new List(); _context.VisitorQuestions.Add(new VisitorQuestion { ConversationId = string.IsNullOrWhiteSpace(request.ConversationId) ? Guid.NewGuid().ToString() : request.ConversationId, InstanceId = request.InstanceId, ConfigurationId = request.ConfigurationId, AppType = request.AppType, IsVoice = request.IsVoice, Language = request.Language, Question = request.Message, Reply = result.Reply, TokensUsed = result.TokensUsed, HasAnswer = sources.Count > 0, CitedContentIds = sources.Select(s => s.ContentId).Distinct().ToList(), CreatedAt = DateTime.UtcNow }); _context.SaveChanges(); } catch (Exception ex) { _logger.LogError(ex, "Journalisation de la question visiteur impossible"); } } /// /// Relance l'indexation complète du contenu d'une instance pour le guide IA. /// /// /// Réservé au SuperAdmin, volontairement. C'est un outil de réparation, pas une /// fonctionnalité : exposé au client, il serait cliqué à chaque réponse décevante du /// guide — alors qu'une mauvaise réponse vient presque toujours d'un contenu trop /// maigre, pas d'un index périmé. Chaque relance recoûte un embedding par morceau de /// toute l'instance, et c'est un moyen trivial de brûler le budget. /// Le rattrapage normal est automatique au passage à un plan avec IA (InstanceController). /// [HttpPost("reindex/{instanceId}")] [Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)] [ProducesResponseType(typeof(object), 202)] [ProducesResponseType(typeof(string), 403)] [ProducesResponseType(typeof(string), 404)] public ObjectResult Reindex(string instanceId) { var instance = _context.Instances.FirstOrDefault(i => i.Id == instanceId); if (instance == null) return new NotFoundObjectResult("Instance inconnue"); // Le job s'arrêterait de toute façon sur la même garde — autant le dire tout de suite. if (instance.AiTokensPerMonth <= 0) return new ObjectResult("L'assistant IA n'est pas inclus dans le plan de cette instance") { StatusCode = 403 }; var sectionCount = _context.Sections.Count(s => s.InstanceId == instanceId); var jobId = _jobs.Enqueue(s => s.BackfillInstanceAsync(instanceId)); // Le nombre de morceaux n'est connu qu'à l'exécution : il part dans les logs et dans // /hangfire. On rend ici de quoi savoir si la relance avait la moindre matière. return new ObjectResult(new { jobId, sectionsQueued = sectionCount }) { StatusCode = 202 }; } /// /// Ce que le guide connaît réellement d'une instance, mesuré sur l'index vectoriel. /// /// /// Tout vient de ContentEmbedding : ce sont les morceaux réellement indexés, donc /// réellement interrogeables. Compter les sections dans Sections donnerait un chiffre /// plus flatteur et faux — une section désactivée est purgée de l'index, une section sans /// texte exploitable n'y entre jamais. /// /// Le nombre de morceaux remplace les « points d'intérêt » de la maquette : les points d'une /// carte sont indexés dans le texte de leur SectionMap, pas comme des contenus autonomes. /// Les compter dans leur propre table répondrait « combien en avez-vous », pas « qu'est-ce /// que le guide en sait » — et afficherait des points appartenant à une section non indexée. /// [HttpGet("knowledge/{instanceId}")] [ProducesResponseType(typeof(GuideKnowledgeDTO), 200)] [ProducesResponseType(typeof(string), 403)] public async Task Knowledge(string instanceId) { if (!IsSuperAdmin() && GetCallerInstanceId() != instanceId) return StatusCode(403, "Instance non autorisée"); var scope = _context.ContentEmbeddings.Where(e => e.InstanceId == instanceId); return Ok(new GuideKnowledgeDTO { indexedSections = await scope .Where(e => e.ContentType == ContentSourceType.Section) .Select(e => e.ContentId) .Distinct() .CountAsync(), chunks = await scope.CountAsync(), languages = await scope .Select(e => e.Language) .Distinct() .OrderBy(l => l) .ToListAsync(), lastIndexedAt = await scope .MaxAsync(e => (DateTime?)e.UpdatedAt) }); } /// /// Ce que les visiteurs ont demandé au guide sur les 30 derniers jours. /// /// /// Remplit exactement la structure attendue par l'onglet « Ce que demandent vos visiteurs » /// de manager-app (GuideIaInsights) — c'est l'affichage qui a fixé le contrat. /// /// topics reste vide tant que le job de regroupement en thèmes n'a pas tourné : /// ThemeId est nul à l'écriture, rempli a posteriori. L'écran dégrade proprement. /// /// Les questions sans réponse sont regroupées sur leur texte exact. Un regroupement /// sémantique dirait mieux la même chose, mais il coûte un embedding par question et /// c'est précisément le travail du job de thèmes — pas d'un endpoint de lecture. /// [HttpGet("insights/{instanceId}")] [ProducesResponseType(typeof(GuideInsightsDTO), 200)] [ProducesResponseType(typeof(string), 403)] public async Task Insights(string instanceId, [FromQuery] int days = 30) { if (!IsSuperAdmin() && GetCallerInstanceId() != instanceId) return StatusCode(403, "Instance non autorisée"); var since = DateTime.UtcNow.AddDays(-Math.Abs(days)); var scope = _context.VisitorQuestions .Where(q => q.InstanceId == instanceId && q.CreatedAt >= since); var citedIds = await scope .SelectMany(q => q.CitedContentIds) .ToListAsync(); // Les titres se résolvent en une seule requête, puis en mémoire : la liste des // contenus cités est courte par nature, elle est déjà tronquée à 6. var topCited = citedIds .GroupBy(id => id) .OrderByDescending(g => g.Count()) .Take(6) .ToList(); var titles = await _context.Sections .Where(s => topCited.Select(g => g.Key).Contains(s.Id)) .ToDictionaryAsync(s => s.Id, s => s.Title); return Ok(new GuideInsightsDTO { questions = await scope.CountAsync(), unanswered = await scope.CountAsync(q => !q.HasAnswer), themes = await scope.Where(q => q.ThemeId != null).Select(q => q.ThemeId).Distinct().CountAsync(), languages = await scope.Select(q => q.Language).Distinct().CountAsync(), unansweredQuestions = await scope .Where(q => !q.HasAnswer) .GroupBy(q => q.Question) .Select(g => new CountedLabelDTO { label = g.Key, count = g.Count() }) .OrderByDescending(x => x.count) .Take(5) .ToListAsync(), topics = await scope .Where(q => q.ThemeId != null) .GroupBy(q => q.ThemeId) .Select(g => new CountedLabelDTO { label = g.Key, count = g.Count() }) .OrderByDescending(x => x.count) .Take(6) .ToListAsync(), questionLanguages = await scope .GroupBy(q => q.Language) .Select(g => new CountedLabelDTO { label = g.Key, count = g.Count() }) .OrderByDescending(x => x.count) .ToListAsync(), citedContents = topCited .Select(g => new CountedLabelDTO { label = titles.TryGetValue(g.Key, out var t) && t != null ? t.FirstOrDefault()?.value ?? g.Key : g.Key, count = g.Count() }) .ToList() }); } /// /// Traduit un texte HTML vers plusieurs langues via IA /// [HttpPost("translate")] [EnableRateLimiting(RateLimitPolicy)] [ProducesResponseType(typeof(AiTranslateResponse), 200)] [ProducesResponseType(403)] [ProducesResponseType(429)] [ProducesResponseType(typeof(string), 500)] public async Task Translate([FromBody] AiTranslateRequest request, [FromQuery] string instanceId) { try { if (!IsSuperAdmin() && instanceId != GetCallerInstanceId()) return Forbid(); var instance = _context.Instances.FirstOrDefault(i => i.Id == instanceId); if (instance == null || !instance.IsAssistant) return Forbid(); var quotaError = CheckQuota(instance); if (quotaError != null) return quotaError; var result = await _assistantService.TranslateAsync(request); RecordUsage(instance, result.TokensUsed); return Ok(result); } catch (Exception ex) { _logger.LogError(ex, "Erreur lors de la traduction IA"); return new ObjectResult("Une erreur est survenue") { StatusCode = 500 }; } } /// /// Envoie un message à l'assistant IA, scopé à l'instance et optionnellement à une configuration /// [HttpPost("chat")] [EnableRateLimiting(RateLimitPolicy)] [ProducesResponseType(typeof(AiChatResponse), 200)] [ProducesResponseType(403)] [ProducesResponseType(429)] [ProducesResponseType(typeof(string), 500)] public async Task Chat([FromBody] AiChatRequest request) { try { if (!IsSuperAdmin() && request.InstanceId != GetCallerInstanceId()) return Forbid(); // Vérifie que l'instance a activé la fonctionnalité assistant var instance = _context.Instances .FirstOrDefault(i => i.Id == request.InstanceId); if (instance == null || !instance.IsAssistant) return Forbid(); // Vérifie que l'app concernée a activé l'assistant // Pour AppType.Voice : fallback sur Mobile si pas d'instance Voice dédiée var appInstance = _context.ApplicationInstances .FirstOrDefault(ai => ai.InstanceId == request.InstanceId && ai.AppType == request.AppType); if (appInstance == null || !appInstance.IsAssistant) return Forbid(); var quotaError = CheckQuota(instance); if (quotaError != null) return quotaError; var result = await _assistantService.ChatAsync(request); RecordUsage(instance, result.TokensUsed); RecordVisitorQuestion(request, result); return Ok(result); } catch (Exception ex) { _logger.LogError(ex, "Erreur lors de l'appel à l'assistant IA"); return new ObjectResult("Une erreur est survenue") { StatusCode = 500 }; } } } }