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>
179 lines
7.7 KiB
C#
179 lines
7.7 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Range les questions visiteurs dans les thèmes de <see cref="QuestionThemes"/>, puis
|
|
/// incrémente les compteurs mensuels de <see cref="QuestionThemeMonthly"/>.
|
|
///
|
|
/// ⚠️ **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 à <see cref="BatchSize"/>
|
|
/// 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.
|
|
/// </summary>
|
|
public class QuestionThemingService
|
|
{
|
|
/// <summary>Questions classées par passage. Borne notre coût, pas la couverture.</summary>
|
|
public const int BatchSize = 500;
|
|
|
|
/// <summary>Questions envoyées au modèle en un appel.</summary>
|
|
private const int ChunkSize = 25;
|
|
|
|
private readonly MyInfoMateDbContext _context;
|
|
private readonly IChatClient _chatClient;
|
|
private readonly ILogger<QuestionThemingService> _logger;
|
|
|
|
public QuestionThemingService(
|
|
MyInfoMateDbContext context,
|
|
IChatClient chatClient,
|
|
ILogger<QuestionThemingService> 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<List<string>> 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<ChatMessage>
|
|
{
|
|
new ChatMessage(ChatRole.User, prompt)
|
|
});
|
|
|
|
var byIndex = new Dictionary<int, string>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Incrémente les compteurs (instance, mois, thème). Les lignes existantes sont
|
|
/// relues et additionnées : le job est rejouable sans doubler un mois.
|
|
/// </summary>
|
|
private async Task UpdateMonthlyAggregatesAsync(IEnumerable<VisitorQuestion> 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();
|
|
}
|
|
}
|
|
}
|