Ingestion et indexation
- IIngestionService/IngestionService : chargement des collections filles par
sous-type, un jeu de morceaux par langue, ChunkIndex continu.
- SectionIndexingInterceptor retenu comme unique déclencheur : les 5
sous-contrôleurs totalisaient 30 SaveChanges et 0 Enqueue, donc ajouter des
points d'intérêt à une carte ne réindexait rien.
- HTML retiré avant l'embedding et lignes trop longues recoupées : sans cela un
article dépassait l'entrée max du modèle et emportait son lot de 50 morceaux.
- Gabarits de LanguageInit filtrés, DistinctBy(Text) avant le Take : ils
occupaient les cinq premiers résultats d'une recherche en néerlandais.
Endpoints du guide IA
- GET /api/Ai/knowledge/{id} : agrégats sur ContentEmbedding, donc sur ce qui
est réellement indexé — compter les sections publiées serait plus flatteur et faux.
- GET /api/Ai/insights/{id} : miroir de GuideIaInsights côté manager-app, c'est
l'écran qui a fixé la forme pour que le job de thèmes la remplisse.
RGPD
- VisitorQuestion journalisée dans AiController.Chat. HasAnswer se déduit des
sources du retrieval, pas du texte : un repli poli ressemble à une réponse.
L'écriture n'échoue jamais la réponse au visiteur.
- VisitorQuestionPurgeService, 90 jours, actif sans condition de configuration :
une durée écrite dans les CGU n'est pas un réglage commercial.
Corrections
- Updateinstance ne recopiait pas les quotas du nouveau plan.
- CheckQuota ne bloquait ni ne comptait à quota 0 — IA gratuite non comptée.
- StoragePath et SizeBytes renseignés à Create, types URL exclus.
dotnet build 0 erreur, dotnet test 130/130.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
382 lines
17 KiB
C#
382 lines
17 KiB
C#
using Hangfire;
|
|
using ManagerService.Data;
|
|
using ManagerService.DTOs;
|
|
using ManagerService.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
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>
|
|
/// 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")]
|
|
[ProducesResponseType(typeof(AiTranslateResponse), 200)]
|
|
[ProducesResponseType(403)]
|
|
[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")]
|
|
[ProducesResponseType(typeof(AiChatResponse), 200)]
|
|
[ProducesResponseType(403)]
|
|
[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 };
|
|
}
|
|
}
|
|
}
|
|
}
|