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();
}
}
}