Thomas Fransolet 118813af58 Les prompts du mode proactif ne sont plus journalisés comme des questions de visiteur
RecordVisitorQuestion écrivait Question = request.Message. Or le prompt d'un
déclenchement automatique est une consigne que le système s'écrit à lui-même
(« Tu es un guide audio de musée. Le visiteur vient d'entrer dans la zone X… ») :
chaque passage devant une œuvre aurait ajouté une fausse question de visiteur
dans l'onglet Guide IA, l'écran fait pour montrer ce que les humains demandent.
Et HasAnswer se déduisant des sources, un prompt d'accueil sans citation aurait
gonflé le bloc ambre des questions sans réponse. Même famille que
WeatherSyncService noyant le journal d'audit.

AiChatRequest.IsAutoTriggered, et Chat qui saute la journalisation.

Tranché : ne rien journaliser, plutôt que marquer d'un drapeau. Une ligne
VisitorQuestion sert au rapport de trous de contenu et aux thèmes du lot J ; un
prompt machine n'alimente ni l'un ni l'autre, et le garder marqué obligerait
chaque futur agrégat à penser à l'exclure — la classe de panne qu'on venait de
fermer sur AuditedTypes.

Mais les jetons restent comptés : ces tours coûtent de l'argent réel au quota du
client, et un audioguide proactif peut en consommer beaucoup. Les 2 tests fixent
la paire — pas de ligne journalisée, compteur incrémenté — parce que c'est la
combinaison qui compte, pas chaque moitié isolément.

dotnet test : 199 passés, 14 sautés, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:14:01 +02:00

402 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 que le système s'est déclenché à lui-même n'est pas une
// question de visiteur : il 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.IsAutoTriggered)
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 };
}
}
}
}