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);
// Les agrégats de thèmes sont mensuels : une fenêtre de 30 jours à cheval sur deux
// mois se lit donc depuis le premier de ces deux mois. Approximation assumée — la
// granularité au jour supposerait de garder les questions, ce que la purge interdit.
var monthOfSince = new DateTime(since.Year, since.Month, 1, 0, 0, 0, DateTimeKind.Utc);
// `CitedContentIds` est une List sérialisée en jsonb par un convertisseur
// de valeur : EF ne sait pas traduire un SelectMany dessus et lève à l'exécution.
// On rapatrie les listes, puis on aplatit côté client.
var citedLists = await scope
.Select(q => q.CitedContentIds)
.ToListAsync();
var citedIds = citedLists
.Where(ids => ids != null)
.SelectMany(ids => ids)
.ToList();
// 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 _context.QuestionThemeMonthlies
.Where(a => a.InstanceId == instanceId && a.Month >= monthOfSince)
.Select(a => a.Theme).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(),
// ⚠️ Lu dans la table d'agrégats, **pas** dans les questions de la fenêtre.
// C'est ce qui tient la promesse du §8.4 des CGU : les questions brutes sont
// purgées à 90 jours, les regroupements survivent. Les lire dans `scope`
// ferait disparaître l'historique du client au 91ᵉ jour, sans erreur ni trace.
topics = await _context.QuestionThemeMonthlies
.Where(a => a.InstanceId == instanceId && a.Month >= monthOfSince)
.GroupBy(a => a.Theme)
.Select(g => new CountedLabelDTO { label = g.Key, count = g.Sum(a => a.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);
// Les jetons sont comptés dans tous les cas — un tour proactif coûte de
// l'argent réel au quota du client, l'exclure du compteur serait pire que
// le bruit qu'on retire juste en dessous.
RecordUsage(instance, result.TokensUsed);
// Mais un tour qui n'est pas une question de visiteur — prompt que le
// système s'est écrit à lui-même, ou gestionnaire qui teste sa personnalité
// dans l'aperçu — ne va ni dans « Ce que demandent vos visiteurs », ni dans
// les trous de contenu, ni dans les thèmes du lot J.
// Et le client peut refuser la collecte : c'est lui le responsable de
// traitement, elle était inconditionnelle dès que l'assistant était actif.
if (request.IsVisitorQuestion && instance.IsVisitorQuestionCollectionEnabled)
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 };
}
}
}
}