424 lines
20 KiB
C#
424 lines
20 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Politique de limitation appliquée aux endpoints qui consomment des jetons.
|
|
/// Déclarée ici parce que c'est le seul contrôleur concerné ; <c>Startup</c> la lit
|
|
/// pour enregistrer le limiteur.
|
|
/// </summary>
|
|
public const string RateLimitPolicy = "ai";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private const long TrialAiTokensCap = 300_000;
|
|
|
|
private readonly IAssistantService _assistantService;
|
|
private readonly MyInfoMateDbContext _context;
|
|
private readonly ILogger<AiController> _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<AiController> 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);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ **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é — <c>ConversationId</c> est un GUID de session — et les
|
|
/// questions brutes se purgent à 90 jours, mais l'information doit être donnée.
|
|
///
|
|
/// <c>HasAnswer</c> 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.
|
|
/// </remarks>
|
|
private void RecordVisitorQuestion(AiChatRequest request, AiChatResponse result)
|
|
{
|
|
try
|
|
{
|
|
var sources = result.Sources ?? new List<AiSourceDTO>();
|
|
|
|
_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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Relance l'indexation complète du contenu d'une instance pour le guide IA.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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).
|
|
/// </remarks>
|
|
[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<IIngestionService>(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 };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ce que le guide connaît réellement d'une instance, mesuré sur l'index vectoriel.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Tout vient de <c>ContentEmbedding</c> : ce sont les morceaux réellement indexés, donc
|
|
/// réellement interrogeables. Compter les sections dans <c>Sections</c> 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.
|
|
/// </remarks>
|
|
[HttpGet("knowledge/{instanceId}")]
|
|
[ProducesResponseType(typeof(GuideKnowledgeDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 403)]
|
|
public async Task<IActionResult> 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)
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ce que les visiteurs ont demandé au guide sur les 30 derniers jours.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Remplit exactement la structure attendue par l'onglet « Ce que demandent vos visiteurs »
|
|
/// de manager-app (<c>GuideIaInsights</c>) — c'est l'affichage qui a fixé le contrat.
|
|
///
|
|
/// <c>topics</c> reste vide tant que le job de regroupement en thèmes n'a pas tourné :
|
|
/// <c>ThemeId</c> 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.
|
|
/// </remarks>
|
|
[HttpGet("insights/{instanceId}")]
|
|
[ProducesResponseType(typeof(GuideInsightsDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 403)]
|
|
public async Task<IActionResult> 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<string> 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()
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Traduit un texte HTML vers plusieurs langues via IA
|
|
/// </summary>
|
|
[HttpPost("translate")]
|
|
[EnableRateLimiting(RateLimitPolicy)]
|
|
[ProducesResponseType(typeof(AiTranslateResponse), 200)]
|
|
[ProducesResponseType(403)]
|
|
[ProducesResponseType(429)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
public async Task<IActionResult> 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 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Envoie un message à l'assistant IA, scopé à l'instance et optionnellement à une configuration
|
|
/// </summary>
|
|
[HttpPost("chat")]
|
|
[EnableRateLimiting(RateLimitPolicy)]
|
|
[ProducesResponseType(typeof(AiChatResponse), 200)]
|
|
[ProducesResponseType(403)]
|
|
[ProducesResponseType(429)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
public async Task<IActionResult> 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 };
|
|
}
|
|
}
|
|
}
|
|
}
|