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>
This commit is contained in:
parent
e3ef4137a5
commit
bd484db48a
@ -194,6 +194,25 @@ namespace ManagerService.Tests.Controllers
|
|||||||
Assert.False(string.IsNullOrWhiteSpace(db.VisitorQuestions.First().ConversationId));
|
Assert.False(string.IsNullOrWhiteSpace(db.VisitorQuestions.First().ConversationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le client est responsable de traitement : couper la collecte doit vraiment
|
||||||
|
/// l'arrêter. Les jetons restent comptés — la question a bien été posée et traitée.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Chat_CollectionDisabled_AnswersButLogsNothing()
|
||||||
|
{
|
||||||
|
using var db = DbContextFactory.Create();
|
||||||
|
SeedAssistantInstance(db);
|
||||||
|
db.Instances.First().IsVisitorQuestionCollectionEnabled = false;
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var result = await BuildController(db).Chat(MakeRequest("i1"));
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result);
|
||||||
|
Assert.Empty(db.VisitorQuestions);
|
||||||
|
Assert.Equal(42, db.Instances.First().AiTokensThisMonth);
|
||||||
|
}
|
||||||
|
|
||||||
private static void SeedAssistantInstance(MyInfoMateDbContext db)
|
private static void SeedAssistantInstance(MyInfoMateDbContext db)
|
||||||
{
|
{
|
||||||
db.Instances.Add(new Instance
|
db.Instances.Add(new Instance
|
||||||
|
|||||||
193
ManagerService.Tests/Services/QuestionThemingServiceTests.cs
Normal file
193
ManagerService.Tests/Services/QuestionThemingServiceTests.cs
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
using ManagerService.Data;
|
||||||
|
using ManagerService.Services;
|
||||||
|
using ManagerService.Tests.Infrastructure;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ManagerService.Tests.Services
|
||||||
|
{
|
||||||
|
public class QuestionThemingServiceTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Le modèle rend une ligne « numéro|thème » par question. Ce faux le rejoue à partir
|
||||||
|
/// des thèmes qu'on lui donne, dans l'ordre.
|
||||||
|
/// </summary>
|
||||||
|
private static QuestionThemingService BuildService(
|
||||||
|
MyInfoMateDbContext db, params string[] themesInOrder)
|
||||||
|
{
|
||||||
|
var reply = string.Join("\n", themesInOrder.Select((t, i) => $"{i + 1}|{t}"));
|
||||||
|
|
||||||
|
var chatClient = new Mock<IChatClient>();
|
||||||
|
chatClient
|
||||||
|
.Setup(c => c.GetResponseAsync(
|
||||||
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||||
|
It.IsAny<ChatOptions>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, reply)));
|
||||||
|
|
||||||
|
return new QuestionThemingService(db, chatClient.Object,
|
||||||
|
NullLogger<QuestionThemingService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VisitorQuestion Question(string id, string instanceId, string text, DateTime createdAt) =>
|
||||||
|
new VisitorQuestion
|
||||||
|
{
|
||||||
|
ConversationId = id,
|
||||||
|
InstanceId = instanceId,
|
||||||
|
Language = "fr",
|
||||||
|
Question = text,
|
||||||
|
Reply = "…",
|
||||||
|
HasAnswer = true,
|
||||||
|
CreatedAt = createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_AssignsThemesAndFillsMonthlyAggregates()
|
||||||
|
{
|
||||||
|
using var db = DbContextFactory.Create();
|
||||||
|
var day = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
db.VisitorQuestions.AddRange(
|
||||||
|
Question("c1", "i1", "Vous ouvrez à quelle heure ?", day),
|
||||||
|
Question("c2", "i1", "C'est accessible en fauteuil ?", day));
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
await BuildService(db, "Horaires et tarifs", "Accessibilité").RunAsync();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "Accessibilité", "Horaires et tarifs" },
|
||||||
|
db.VisitorQuestions.Select(q => q.ThemeId).OrderBy(t => t).ToArray());
|
||||||
|
|
||||||
|
var aggregates = db.QuestionThemeMonthlies.OrderBy(a => a.Theme).ToList();
|
||||||
|
Assert.Equal(2, aggregates.Count);
|
||||||
|
Assert.All(aggregates, a => Assert.Equal(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), a.Month));
|
||||||
|
Assert.All(aggregates, a => Assert.Equal(1, a.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le job est rejouable : un second passage ne doit pas doubler un mois. Les questions
|
||||||
|
/// déjà classées sont hors de son périmètre, donc les compteurs ne bougent pas.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_IsIdempotent_AlreadyThemedQuestionsAreLeftAlone()
|
||||||
|
{
|
||||||
|
using var db = DbContextFactory.Create();
|
||||||
|
db.VisitorQuestions.Add(Question("c1", "i1", "Vous ouvrez quand ?", DateTime.UtcNow));
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
await BuildService(db, "Horaires et tarifs").RunAsync();
|
||||||
|
await BuildService(db, "Horaires et tarifs").RunAsync();
|
||||||
|
|
||||||
|
var aggregate = Assert.Single(db.QuestionThemeMonthlies);
|
||||||
|
Assert.Equal(1, aggregate.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Les compteurs d'un même mois s'additionnent d'un passage à l'autre — c'est ce qui
|
||||||
|
/// permet au job de tourner tous les jours sans écraser le début du mois.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_AddsToAnExistingMonth()
|
||||||
|
{
|
||||||
|
using var db = DbContextFactory.Create();
|
||||||
|
var day = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
db.VisitorQuestions.Add(Question("c1", "i1", "Horaires ?", day));
|
||||||
|
db.SaveChanges();
|
||||||
|
await BuildService(db, "Horaires et tarifs").RunAsync();
|
||||||
|
|
||||||
|
db.VisitorQuestions.Add(Question("c2", "i1", "Et le dimanche ?", day.AddDays(3)));
|
||||||
|
db.SaveChanges();
|
||||||
|
await BuildService(db, "Horaires et tarifs").RunAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, Assert.Single(db.QuestionThemeMonthlies).Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Un thème inventé par le modèle ne doit pas créer de ligne d'agrégat parasite :
|
||||||
|
/// deux mois ne se comparent que si les libellés sont stables.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_UnknownThemeFallsBackToOther()
|
||||||
|
{
|
||||||
|
using var db = DbContextFactory.Create();
|
||||||
|
db.VisitorQuestions.Add(Question("c1", "i1", "Une question", DateTime.UtcNow));
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
await BuildService(db, "Questions diverses sur le musée").RunAsync();
|
||||||
|
|
||||||
|
Assert.Equal(QuestionThemes.Other, db.VisitorQuestions.First().ThemeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠️ Le point qui protège de la perte : une réponse du modèle à laquelle il manque une
|
||||||
|
/// ligne ne doit pas décaler les suivantes. Ici la ligne 1 manque — la question 2 doit
|
||||||
|
/// garder SON thème, pas hériter de celui de la 3.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_MissingLineDoesNotShiftTheOthers()
|
||||||
|
{
|
||||||
|
using var db = DbContextFactory.Create();
|
||||||
|
var day = DateTime.UtcNow;
|
||||||
|
db.VisitorQuestions.AddRange(
|
||||||
|
Question("c1", "i1", "Première", day),
|
||||||
|
Question("c2", "i1", "Deuxième", day.AddMinutes(1)),
|
||||||
|
Question("c3", "i1", "Troisième", day.AddMinutes(2)));
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var chatClient = new Mock<IChatClient>();
|
||||||
|
chatClient
|
||||||
|
.Setup(c => c.GetResponseAsync(
|
||||||
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||||
|
It.IsAny<ChatOptions>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant,
|
||||||
|
"2|Accessibilité\n3|Services sur place")));
|
||||||
|
|
||||||
|
await new QuestionThemingService(db, chatClient.Object,
|
||||||
|
NullLogger<QuestionThemingService>.Instance).RunAsync();
|
||||||
|
|
||||||
|
var byQuestion = db.VisitorQuestions.ToDictionary(q => q.Question, q => q.ThemeId);
|
||||||
|
Assert.Equal(QuestionThemes.Other, byQuestion["Première"]);
|
||||||
|
Assert.Equal("Accessibilité", byQuestion["Deuxième"]);
|
||||||
|
Assert.Equal("Services sur place", byQuestion["Troisième"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Les plus anciennes d'abord : c'est ce qui fait que le plafond par passage retarde
|
||||||
|
/// sans jamais sauter une question, et donc qu'aucune ne soit purgée non classée.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_ProcessesOldestFirst()
|
||||||
|
{
|
||||||
|
using var db = DbContextFactory.Create();
|
||||||
|
var old = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
db.VisitorQuestions.AddRange(
|
||||||
|
Question("c-recent", "i1", "Récente", old.AddDays(30)),
|
||||||
|
Question("c-old", "i1", "Ancienne", old));
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var captured = new List<string>();
|
||||||
|
var chatClient = new Mock<IChatClient>();
|
||||||
|
chatClient
|
||||||
|
.Setup(c => c.GetResponseAsync(
|
||||||
|
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||||
|
It.IsAny<ChatOptions>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>(
|
||||||
|
(messages, _, __) => captured.Add(messages.First().Text ?? ""))
|
||||||
|
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "1|Autre\n2|Autre")));
|
||||||
|
|
||||||
|
await new QuestionThemingService(db, chatClient.Object,
|
||||||
|
NullLogger<QuestionThemingService>.Instance).RunAsync();
|
||||||
|
|
||||||
|
var prompt = Assert.Single(captured);
|
||||||
|
Assert.Contains("1. Ancienne", prompt);
|
||||||
|
Assert.Contains("2. Récente", prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -249,6 +249,11 @@ namespace ManagerService.Controllers
|
|||||||
var scope = _context.VisitorQuestions
|
var scope = _context.VisitorQuestions
|
||||||
.Where(q => q.InstanceId == instanceId && q.CreatedAt >= since);
|
.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
|
var citedIds = await scope
|
||||||
.SelectMany(q => q.CitedContentIds)
|
.SelectMany(q => q.CitedContentIds)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@ -268,7 +273,9 @@ namespace ManagerService.Controllers
|
|||||||
{
|
{
|
||||||
questions = await scope.CountAsync(),
|
questions = await scope.CountAsync(),
|
||||||
unanswered = await scope.CountAsync(q => !q.HasAnswer),
|
unanswered = await scope.CountAsync(q => !q.HasAnswer),
|
||||||
themes = await scope.Where(q => q.ThemeId != null).Select(q => q.ThemeId).Distinct().CountAsync(),
|
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(),
|
languages = await scope.Select(q => q.Language).Distinct().CountAsync(),
|
||||||
|
|
||||||
unansweredQuestions = await scope
|
unansweredQuestions = await scope
|
||||||
@ -279,10 +286,14 @@ namespace ManagerService.Controllers
|
|||||||
.Take(5)
|
.Take(5)
|
||||||
.ToListAsync(),
|
.ToListAsync(),
|
||||||
|
|
||||||
topics = await scope
|
// ⚠️ Lu dans la table d'agrégats, **pas** dans les questions de la fenêtre.
|
||||||
.Where(q => q.ThemeId != null)
|
// C'est ce qui tient la promesse du §8.4 des CGU : les questions brutes sont
|
||||||
.GroupBy(q => q.ThemeId)
|
// purgées à 90 jours, les regroupements survivent. Les lire dans `scope`
|
||||||
.Select(g => new CountedLabelDTO { label = g.Key, count = g.Count() })
|
// 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)
|
.OrderByDescending(x => x.count)
|
||||||
.Take(6)
|
.Take(6)
|
||||||
.ToListAsync(),
|
.ToListAsync(),
|
||||||
@ -387,7 +398,9 @@ namespace ManagerService.Controllers
|
|||||||
// système s'est écrit à lui-même, ou gestionnaire qui teste sa personnalité
|
// 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
|
// 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.
|
// les trous de contenu, ni dans les thèmes du lot J.
|
||||||
if (request.IsVisitorQuestion)
|
// 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);
|
RecordVisitorQuestion(request, result);
|
||||||
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
|
|||||||
@ -21,6 +21,8 @@ namespace ManagerService.DTOs
|
|||||||
public string? guideName { get; set; }
|
public string? guideName { get; set; }
|
||||||
public string? guidePersonaPrompt { get; set; }
|
public string? guidePersonaPrompt { get; set; }
|
||||||
public string? guideVoiceId { get; set; }
|
public string? guideVoiceId { get; set; }
|
||||||
|
/// <summary>Le client autorise-t-il l'enregistrement des questions de ses visiteurs ? (RGPD, lot J)</summary>
|
||||||
|
public bool? isVisitorQuestionCollectionEnabled { get; set; }
|
||||||
public List<TranslationDTO>? guideFallbackMessages { get; set; }
|
public List<TranslationDTO>? guideFallbackMessages { get; set; }
|
||||||
|
|
||||||
public string? webSlug { get; set; }
|
public string? webSlug { get; set; }
|
||||||
|
|||||||
@ -54,6 +54,21 @@ namespace ManagerService.Data
|
|||||||
/// <summary>Voix Gemini TTS : "Sulafat" (Viva, féminine) ou "Umbriel" (Marco, masculine).</summary>
|
/// <summary>Voix Gemini TTS : "Sulafat" (Viva, féminine) ou "Umbriel" (Marco, masculine).</summary>
|
||||||
public string? GuideVoiceId { get; set; }
|
public string? GuideVoiceId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le client autorise-t-il l'enregistrement des questions posées par ses visiteurs ?
|
||||||
|
///
|
||||||
|
/// ⚠️ **C'est lui le responsable de traitement**, pas nous : il doit pouvoir refuser
|
||||||
|
/// la collecte. Elle était inconditionnelle dès que l'assistant était actif.
|
||||||
|
///
|
||||||
|
/// Défaut à <c>true</c> : couvert par les CGU §8, et le désactiver par défaut viderait
|
||||||
|
/// l'onglet « Ce que demandent vos visiteurs » et le rapport de trous de contenu chez
|
||||||
|
/// tout le monde — une fonction qu'il paie, perdue sans l'avoir demandé.
|
||||||
|
///
|
||||||
|
/// ⚠️ Couper la collecte n'efface rien : les questions déjà enregistrées vivent
|
||||||
|
/// jusqu'à leur purge à 90 jours, et les agrégats de thèmes leur survivent.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsVisitorQuestionCollectionEnabled { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Formulations utilisées quand le guide ne trouve pas de réponse.
|
/// Formulations utilisées quand le guide ne trouve pas de réponse.
|
||||||
/// Liste à plat : plusieurs entrées peuvent partager la même langue,
|
/// Liste à plat : plusieurs entrées peuvent partager la même langue,
|
||||||
@ -128,6 +143,7 @@ namespace ManagerService.Data
|
|||||||
guideName = GuideName,
|
guideName = GuideName,
|
||||||
guidePersonaPrompt = GuidePersonaPrompt,
|
guidePersonaPrompt = GuidePersonaPrompt,
|
||||||
guideVoiceId = GuideVoiceId,
|
guideVoiceId = GuideVoiceId,
|
||||||
|
isVisitorQuestionCollectionEnabled = IsVisitorQuestionCollectionEnabled,
|
||||||
guideFallbackMessages = GuideFallbackMessages,
|
guideFallbackMessages = GuideFallbackMessages,
|
||||||
webSlug = WebSlug,
|
webSlug = WebSlug,
|
||||||
publicApiKey = PublicApiKey,
|
publicApiKey = PublicApiKey,
|
||||||
@ -169,6 +185,8 @@ namespace ManagerService.Data
|
|||||||
GuidePersonaPrompt = instanceDTO.guidePersonaPrompt;
|
GuidePersonaPrompt = instanceDTO.guidePersonaPrompt;
|
||||||
if (instanceDTO.guideVoiceId != null)
|
if (instanceDTO.guideVoiceId != null)
|
||||||
GuideVoiceId = instanceDTO.guideVoiceId;
|
GuideVoiceId = instanceDTO.guideVoiceId;
|
||||||
|
if (instanceDTO.isVisitorQuestionCollectionEnabled != null)
|
||||||
|
IsVisitorQuestionCollectionEnabled = instanceDTO.isVisitorQuestionCollectionEnabled.Value;
|
||||||
if (instanceDTO.guideFallbackMessages != null)
|
if (instanceDTO.guideFallbackMessages != null)
|
||||||
GuideFallbackMessages = instanceDTO.guideFallbackMessages;
|
GuideFallbackMessages = instanceDTO.guideFallbackMessages;
|
||||||
if (instanceDTO.webSlug != null)
|
if (instanceDTO.webSlug != null)
|
||||||
|
|||||||
@ -75,6 +75,7 @@ namespace ManagerService.Data
|
|||||||
|
|
||||||
// Guide IA — journal des questions visiteurs
|
// Guide IA — journal des questions visiteurs
|
||||||
public DbSet<VisitorQuestion> VisitorQuestions { get; set; }
|
public DbSet<VisitorQuestion> VisitorQuestions { get; set; }
|
||||||
|
public DbSet<QuestionThemeMonthly> QuestionThemeMonthlies { get; set; }
|
||||||
|
|
||||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||||
{
|
{
|
||||||
|
|||||||
38
ManagerService/Data/MyInfoMateDbContextFactory.cs
Normal file
38
ManagerService/Data/MyInfoMateDbContextFactory.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ManagerService.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fabrique utilisée **uniquement par les outils EF** (`dotnet ef migrations add`).
|
||||||
|
///
|
||||||
|
/// ⚠️ Sans elle, EF construit tout l'hôte pour trouver le contexte — et l'hôte ouvre une
|
||||||
|
/// connexion PostgreSQL au démarrage (stockage Hangfire). Générer une migration exigeait
|
||||||
|
/// donc une base joignable, alors qu'écrire un fichier de migration n'a besoin d'aucune
|
||||||
|
/// base. Sur une machine sans Postgres ni Docker, c'était tout simplement impossible.
|
||||||
|
///
|
||||||
|
/// La chaîne de connexion n'est jamais utilisée pour se connecter à ce stade ; elle doit
|
||||||
|
/// seulement être syntaxiquement valide. <c>MIGRATIONS_CONNECTION</c> permet de la
|
||||||
|
/// surcharger pour un <c>database update</c> ponctuel.
|
||||||
|
/// </summary>
|
||||||
|
public class MyInfoMateDbContextFactory : IDesignTimeDbContextFactory<MyInfoMateDbContext>
|
||||||
|
{
|
||||||
|
public MyInfoMateDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var connectionString = Environment.GetEnvironmentVariable("MIGRATIONS_CONNECTION")
|
||||||
|
?? "Host=localhost;Port=5432;Database=my_info_mate;Username=postgres;Password=postgres";
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<MyInfoMateDbContext>()
|
||||||
|
.UseNpgsql(connectionString, o =>
|
||||||
|
{
|
||||||
|
o.UseNetTopologySuite();
|
||||||
|
o.UseVector();
|
||||||
|
})
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new MyInfoMateDbContext(options, new HttpContextAccessor());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
ManagerService/Data/QuestionThemeMonthly.cs
Normal file
40
ManagerService/Data/QuestionThemeMonthly.cs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ManagerService.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Compteur mensuel de questions par thème, pour une instance.
|
||||||
|
///
|
||||||
|
/// ⚠️ **C'est cette table qui rend tenable la promesse du §8.4 des CGU** — « les
|
||||||
|
/// regroupements par thème sont conservés au-delà, sous forme agrégée ». Sans elle, le
|
||||||
|
/// regroupement vit dans <see cref="VisitorQuestion.ThemeId"/>, donc la purge du 90ᵉ jour
|
||||||
|
/// l'emporte avec la question : le client perdrait tout son historique au 91ᵉ jour.
|
||||||
|
///
|
||||||
|
/// Elle ne porte **que des compteurs** : ni texte de question, ni identifiant de session,
|
||||||
|
/// ni langue. Aucune donnée personnelle, donc rien qui justifierait de la purger — c'est
|
||||||
|
/// précisément ce qui l'autorise à survivre aux questions dont elle est issue.
|
||||||
|
/// </summary>
|
||||||
|
[Index(nameof(InstanceId), nameof(Month))]
|
||||||
|
public class QuestionThemeMonthly
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public string InstanceId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Premier jour du mois concerné, en UTC. La granularité est le mois, pas le jour.</summary>
|
||||||
|
public DateTime Month { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Libellé du thème, pris dans <see cref="Services.QuestionThemes.All"/>.
|
||||||
|
///
|
||||||
|
/// ⚠️ Volontairement une liste fixe et non des thèmes découverts par l'IA : deux mois
|
||||||
|
/// ne se comparent que si leurs thèmes portent le même nom. Des libellés régénérés à
|
||||||
|
/// chaque passage produiraient « Horaires » en janvier et « Questions d'horaires » en
|
||||||
|
/// février — deux lignes distinctes, et une courbe qui ne veut rien dire.
|
||||||
|
/// </summary>
|
||||||
|
public string Theme { get; set; }
|
||||||
|
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
1913
ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.Designer.cs
generated
Normal file
1913
ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ManagerService.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class LotJ_ThemeAggregatesAndCollectionSwitch : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsVisitorQuestionCollectionEnabled",
|
||||||
|
table: "Instances",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "QuestionThemeMonthlies",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
InstanceId = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Month = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
Theme = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Count = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_QuestionThemeMonthlies", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_QuestionThemeMonthlies_InstanceId_Month",
|
||||||
|
table: "QuestionThemeMonthlies",
|
||||||
|
columns: new[] { "InstanceId", "Month" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "QuestionThemeMonthlies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsVisitorQuestionCollectionEnabled",
|
||||||
|
table: "Instances");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -483,6 +483,9 @@ namespace ManagerService.Migrations
|
|||||||
b.Property<bool>("IsVR")
|
b.Property<bool>("IsVR")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsVisitorQuestionCollectionEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("IsWeb")
|
b.Property<bool>("IsWeb")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@ -585,6 +588,33 @@ namespace ManagerService.Migrations
|
|||||||
b.ToTable("PushNotifications");
|
b.ToTable("PushNotifications");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ManagerService.Data.QuestionThemeMonthly", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("Count")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("InstanceId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Month")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("InstanceId", "Month");
|
||||||
|
|
||||||
|
b.ToTable("QuestionThemeMonthlies");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ManagerService.Data.Resource", b =>
|
modelBuilder.Entity("ManagerService.Data.Resource", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
|
|||||||
49
ManagerService/Services/QuestionThemes.cs
Normal file
49
ManagerService/Services/QuestionThemes.cs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace ManagerService.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Liste fixe des thèmes de questions visiteurs.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Fixe, et c'est le point de conception.** Des thèmes découverts librement par l'IA
|
||||||
|
/// colleraient mieux à chaque lieu, mais les agrégats mensuels ne se compareraient plus :
|
||||||
|
/// « Horaires » en janvier et « Questions d'horaires » en février seraient deux lignes
|
||||||
|
/// distinctes, et la courbe que le client regarde ne voudrait rien dire. Or la table
|
||||||
|
/// d'agrégats existe précisément pour survivre à la purge et porter cet historique.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Ajouter un thème est possible, en renommer un ne l'est pas** : le libellé est la
|
||||||
|
/// clé de <see cref="Data.QuestionThemeMonthly.Theme"/>, donc le renommer coupe l'historique
|
||||||
|
/// en deux. Un nouveau thème, lui, apparaît simplement à partir du mois où il est ajouté.
|
||||||
|
/// </summary>
|
||||||
|
public static class QuestionThemes
|
||||||
|
{
|
||||||
|
public const string Other = "Autre";
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<string> All = new[]
|
||||||
|
{
|
||||||
|
"Horaires et tarifs",
|
||||||
|
"Accès et transport",
|
||||||
|
"Accessibilité",
|
||||||
|
"Œuvres et contenu",
|
||||||
|
"Services sur place",
|
||||||
|
"Événements et animations",
|
||||||
|
"Orientation dans le lieu",
|
||||||
|
Other,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ramène la réponse du modèle sur un thème connu. Une valeur inattendue tombe dans
|
||||||
|
/// « Autre » plutôt que de créer une ligne d'agrégat parasite — le modèle peut
|
||||||
|
/// paraphraser, la table ne doit pas en souffrir.
|
||||||
|
/// </summary>
|
||||||
|
public static string Normalize(string raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return Other;
|
||||||
|
|
||||||
|
var trimmed = raw.Trim().Trim('"', '.', '-', ' ');
|
||||||
|
return All.FirstOrDefault(t => string.Equals(t, trimmed, System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? Other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
ManagerService/Services/QuestionThemingService.cs
Normal file
178
ManagerService/Services/QuestionThemingService.cs
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -216,6 +216,7 @@ namespace ManagerService
|
|||||||
services.AddScoped<TrialLifecycleService>();
|
services.AddScoped<TrialLifecycleService>();
|
||||||
services.AddScoped<VisitEventPurgeService>();
|
services.AddScoped<VisitEventPurgeService>();
|
||||||
services.AddScoped<VisitorQuestionPurgeService>();
|
services.AddScoped<VisitorQuestionPurgeService>();
|
||||||
|
services.AddScoped<QuestionThemingService>();
|
||||||
|
|
||||||
var connectionString = Configuration.GetConnectionString("PostgresConnection");
|
var connectionString = Configuration.GetConnectionString("PostgresConnection");
|
||||||
|
|
||||||
@ -378,6 +379,14 @@ namespace ManagerService
|
|||||||
s => s.PurgeAsync(),
|
s => s.PurgeAsync(),
|
||||||
"0 3 * * *");
|
"0 3 * * *");
|
||||||
|
|
||||||
|
// ⚠️ Le regroupement passe AVANT la purge dans la nuit — 2 h contre 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 : elle n'existe plus.
|
||||||
|
RecurringJob.AddOrUpdate<QuestionThemingService>(
|
||||||
|
"visitor-questions-theming",
|
||||||
|
s => s.RunAsync(),
|
||||||
|
"0 2 * * *");
|
||||||
|
|
||||||
// Actif sans condition : les 90 jours sont un engagement des CGU §8.4, pas un réglage.
|
// Actif sans condition : les 90 jours sont un engagement des CGU §8.4, pas un réglage.
|
||||||
RecurringJob.AddOrUpdate<VisitorQuestionPurgeService>(
|
RecurringJob.AddOrUpdate<VisitorQuestionPurgeService>(
|
||||||
"visitor-questions-purge",
|
"visitor-questions-purge",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user