La cle API publique d'une instance est embarquee dans les apps visiteur et lisible en clair dans le navigateur sur visitapp-web : elle est publique par construction. Les endpoints IA sont les seuls qui coutent de l'argent reel (jetons Gemini), et rien n'empechait d'y boucler jusqu'a vider le quota mensuel d'un client qui n'a rien fait. AddRateLimiter natif .NET 8, fenetre fixe 120 req/min, 429 avec Retry-After. Applique a chat ET translate : les deux consomment des jetons, et translate est atteignable avec la meme cle. Partition par instance parce que c'est l'instance qui porte le quota protege : l'abus chez un client ne doit pas ralentir les autres. Deux choix de placement qui ne sont pas cosmetiques. UseRateLimiter est apres UseCors — un 429 pose avant les en-tetes CORS s'affiche comme une erreur CORS et le client ne voit jamais le vrai code — et apres UseAuthentication, sinon la partition n'a pas le claim d'instance et tout le monde tombe dans le meme seau, ce qui transformerait la protection en panne globale. Jamais exerce a l'execution : le projet n'a aucune infrastructure de test HTTP et en monter une pour ce seul controle serait disproportionne. A verifier une fois par une boucle de 130 appels, qui doit basculer en 429 au 121e. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
394 lines
18 KiB
C#
394 lines
18 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);
|
|
|
|
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()
|
|
});
|
|
}
|
|
|
|
/// <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);
|
|
|
|
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 };
|
|
}
|
|
}
|
|
}
|
|
}
|