using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using ManagerService.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; namespace ManagerService.Services { /// /// Range les questions visiteurs dans les thèmes de , puis /// incrémente les compteurs mensuels de . /// /// ⚠️ **Quotidien, pas mensuel, et c'est ce qui empêche de perdre des données.** Le job /// traite les questions **les plus anciennes d'abord** et s'arrête à /// par passage : le plafond borne notre coût sans jamais sauter une question, il ne fait /// que retarder. Comme la purge tombe à 90 jours, il faudrait un volume soutenu supérieur /// au plafond **chaque jour pendant trois mois** pour qu'une question disparaisse avant /// d'avoir été classée — d'où l'avertissement émis dès que le retard dépasse un passage. /// /// ⚠️ **Les jetons ne sont pas décomptés du quota du client.** Il n'a pas demandé ces /// appels : c'est nous qui les déclenchons pour alimenter un écran qu'on lui vend. Les /// imputer reviendrait à réduire, un mois chargé, le quota qui sert à répondre à ses /// visiteurs — le punir de son succès. /// public class QuestionThemingService { /// Questions classées par passage. Borne notre coût, pas la couverture. public const int BatchSize = 500; /// Questions envoyées au modèle en un appel. private const int ChunkSize = 25; private readonly MyInfoMateDbContext _context; private readonly IChatClient _chatClient; private readonly ILogger _logger; public QuestionThemingService( MyInfoMateDbContext context, IChatClient chatClient, ILogger logger) { _context = context; _chatClient = chatClient; _logger = logger; } public async Task RunAsync() { var pending = await _context.VisitorQuestions .Where(q => q.ThemeId == null) .OrderBy(q => q.CreatedAt) .Take(BatchSize) .ToListAsync(); if (pending.Count == 0) return; if (pending.Count == BatchSize) { var backlog = await _context.VisitorQuestions.CountAsync(q => q.ThemeId == null); _logger.LogWarning( "Regroupement en thèmes : {Backlog} question(s) en attente pour un plafond de {BatchSize} par passage. " + "Le retard se résorbe au rythme d'un passage par jour ; s'il ne baisse pas, relever le plafond " + "avant que la purge à 90 jours n'emporte des questions jamais classées.", backlog, BatchSize); } foreach (var chunk in pending.Chunk(ChunkSize)) { try { var themes = await ClassifyAsync(chunk); for (var i = 0; i < chunk.Length; i++) { chunk[i].ThemeId = i < themes.Count ? themes[i] : QuestionThemes.Other; } } catch (Exception ex) { // Un lot qui échoue laisse ses questions non classées : elles repasseront // au prochain tour, en tête puisque ce sont les plus anciennes. Surtout // ne pas les marquer « Autre » pour s'en débarrasser — ce serait une // perte définitive maquillée en résultat. _logger.LogError(ex, "Regroupement en thèmes : lot de {Count} question(s) en échec, reporté.", chunk.Length); } } await _context.SaveChangesAsync(); await UpdateMonthlyAggregatesAsync(pending.Where(q => q.ThemeId != null)); } /// /// Une seule requête pour tout le lot : un appel par question multiplierait le coût /// par 25 pour un travail que le modèle fait aussi bien en bloc. /// private async Task> ClassifyAsync(VisitorQuestion[] questions) { var list = string.Join("\n", questions.Select((q, i) => $"{i + 1}. {q.Question}")); var themeList = string.Join("\n", QuestionThemes.All.Select(t => $"- {t}")); var prompt = $""" Tu classes des questions posées par des visiteurs d'un lieu culturel. Range chaque question dans EXACTEMENT UN de ces thèmes, sans en inventer d'autres : {themeList} Si aucun ne convient, réponds "{QuestionThemes.Other}". Réponds UNIQUEMENT avec une ligne par question, dans l'ordre, au format "numéro|thème". Aucune explication, aucun markdown. Questions : {list} """; var response = await _chatClient.GetResponseAsync(new List { new ChatMessage(ChatRole.User, prompt) }); var byIndex = new Dictionary(); foreach (var line in (response.Text ?? "").Split('\n', StringSplitOptions.RemoveEmptyEntries)) { var parts = line.Split('|', 2); if (parts.Length != 2) continue; if (!int.TryParse(parts[0].Trim().TrimStart('-', ' ', '.'), out var number)) continue; byIndex[number] = QuestionThemes.Normalize(parts[1]); } // Une ligne manquante ou hors format ne décale pas les suivantes : on relit par // numéro, jamais par position. Un modèle qui saute une ligne fausserait sinon // tout le reste du lot. return Enumerable.Range(1, questions.Length) .Select(n => byIndex.TryGetValue(n, out var theme) ? theme : QuestionThemes.Other) .ToList(); } /// /// Incrémente les compteurs (instance, mois, thème). Les lignes existantes sont /// relues et additionnées : le job est rejouable sans doubler un mois. /// private async Task UpdateMonthlyAggregatesAsync(IEnumerable classified) { var increments = classified .GroupBy(q => new { q.InstanceId, Month = new DateTime(q.CreatedAt.Year, q.CreatedAt.Month, 1, 0, 0, 0, DateTimeKind.Utc), Theme = q.ThemeId }) .Select(g => new { g.Key.InstanceId, g.Key.Month, g.Key.Theme, Count = g.Count() }) .ToList(); foreach (var inc in increments) { var row = await _context.QuestionThemeMonthlies.FirstOrDefaultAsync( a => a.InstanceId == inc.InstanceId && a.Month == inc.Month && a.Theme == inc.Theme); if (row == null) { _context.QuestionThemeMonthlies.Add(new QuestionThemeMonthly { InstanceId = inc.InstanceId, Month = inc.Month, Theme = inc.Theme, Count = inc.Count }); } else { row.Count += inc.Count; } } await _context.SaveChangesAsync(); } } }