VoiceSessions : nombre de sessions ayant utilisé le vocal au moins une fois, lu dans le JSON de Metadata. Volontairement hors d'AppTypeDistribution — le vocal n'est pas une plateforme mais un mode d'interaction : un visiteur passe des lunettes à l'écran dans une même session, or la distribution compte une entrée par session tranchée sur l'événement le plus ancien. Le chiffre aurait dépendu de l'ordre dans lequel le visiteur a touché ses appareils. Colonne Metadata déjà existante : aucune migration, le gel du lot B tient. Le test InMemory ne prouve rien ici — le provider évalue le filtre côté client, donc une requête intraduisible passerait au vert. Un test Postgres l'accompagne. IsAutoTriggered devient IsVisitorQuestion (défaut true). Le nom d'hier ne couvrait qu'un des deux cas : le vrai sens n'est pas « déclenché automatiquement » mais « ce tour n'est pas une question de visiteur », ce qui vaut aussi pour l'aperçu de conversation, où c'est le gestionnaire qui teste sa personnalité. Renommé pendant qu'un seul commit en dépendait. Le défaut à true est délibéré : une app visiteur publiée qui n'envoie pas le champ continue de journaliser, et un test le fixe. dotnet test : 204 passés, 14 sautés, 0 échec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
403 lines
18 KiB
C#
403 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);
|
|
|
|
// 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.
|
|
if (request.IsVisitorQuestion)
|
|
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 };
|
|
}
|
|
}
|
|
}
|
|
}
|