Thomas Fransolet bd484db48a Lot J : agrégats de thèmes, job de regroupement, interrupteur de collecte
Table QuestionThemeMonthly (instance, mois, thème, compteur). C'est elle qui rend
tenable le §8.4 des CGU : le regroupement vivait dans ThemeId, colonne de la ligne
VisitorQuestion, donc la purge du 90e jour l'emportait avec la question et le
client perdait tout au 91e. Elle ne porte que des compteurs — aucune donnée
personnelle, ce qui est précisément ce qui l'autorise à survivre. Insights lit
désormais les thèmes dans cette table, pas dans les questions de la fenêtre.

Liste fixe de 8 thèmes, pas de thèmes découverts par l'IA : des libellés
régénérés à chaque passage donneraient « Horaires » en janvier et « Questions
d'horaires » en février, deux lignes distinctes et une courbe qui ne veut rien
dire — alors que la table existe pour porter cet historique.

Le job tourne à 2 h, la purge à 3 h 30 : une question purgée avant d'avoir été
classée ne compte dans aucun agrégat et rien ne peut la rattraper. Le plafond de
500 par passage ne perd rien, il retarde — les plus anciennes d'abord, un passage
par jour, avertissement si le retard dépasse un passage. Les jetons ne sont pas
décomptés du quota client : il n'a pas demandé ces appels. Un lot en échec n'est
pas marqué « Autre » pour s'en débarrasser, ce serait une perte définitive
maquillée en résultat ; et la relecture se fait par numéro, jamais par position,
pour qu'une ligne manquante ne décale pas les suivantes.

Instance.IsVisitorQuestionCollectionEnabled (défaut true) + garde dans Chat : le
client est responsable de traitement, la collecte était inconditionnelle.

Ajout d'une fabrique design-time : EF construisait tout l'hôte pour trouver le
contexte, et l'hôte ouvre une connexion au démarrage — générer une migration
exigeait donc une base joignable, impossible sur une machine sans Postgres ni
Docker.

dotnet test : 211 passés, 15 sautés, 0 échec.

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

416 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);
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 _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 };
}
}
}
}