diff --git a/ManagerService.Tests/Controllers/AiControllerTests.cs b/ManagerService.Tests/Controllers/AiControllerTests.cs index 82a7463..e52959d 100644 --- a/ManagerService.Tests/Controllers/AiControllerTests.cs +++ b/ManagerService.Tests/Controllers/AiControllerTests.cs @@ -194,6 +194,25 @@ namespace ManagerService.Tests.Controllers Assert.False(string.IsNullOrWhiteSpace(db.VisitorQuestions.First().ConversationId)); } + /// + /// 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. + /// + [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(result); + Assert.Empty(db.VisitorQuestions); + Assert.Equal(42, db.Instances.First().AiTokensThisMonth); + } + private static void SeedAssistantInstance(MyInfoMateDbContext db) { db.Instances.Add(new Instance diff --git a/ManagerService.Tests/Services/QuestionThemingServiceTests.cs b/ManagerService.Tests/Services/QuestionThemingServiceTests.cs new file mode 100644 index 0000000..d30f738 --- /dev/null +++ b/ManagerService.Tests/Services/QuestionThemingServiceTests.cs @@ -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 + { + /// + /// 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. + /// + 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(); + chatClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, reply))); + + return new QuestionThemingService(db, chatClient.Object, + NullLogger.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)); + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// ⚠️ 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. + /// + [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(); + chatClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, + "2|Accessibilité\n3|Services sur place"))); + + await new QuestionThemingService(db, chatClient.Object, + NullLogger.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"]); + } + + /// + /// 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. + /// + [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(); + var chatClient = new Mock(); + chatClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, 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.Instance).RunAsync(); + + var prompt = Assert.Single(captured); + Assert.Contains("1. Ancienne", prompt); + Assert.Contains("2. Récente", prompt); + } + } +} diff --git a/ManagerService/Controllers/AiController.cs b/ManagerService/Controllers/AiController.cs index 7aead48..c797440 100644 --- a/ManagerService/Controllers/AiController.cs +++ b/ManagerService/Controllers/AiController.cs @@ -249,6 +249,11 @@ namespace ManagerService.Controllers var scope = _context.VisitorQuestions .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 .SelectMany(q => q.CitedContentIds) .ToListAsync(); @@ -268,7 +273,9 @@ namespace ManagerService.Controllers { 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(), + 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(), unansweredQuestions = await scope @@ -279,10 +286,14 @@ namespace ManagerService.Controllers .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() }) + // ⚠️ Lu dans la table d'agrégats, **pas** dans les questions de la fenêtre. + // C'est ce qui tient la promesse du §8.4 des CGU : les questions brutes sont + // purgées à 90 jours, les regroupements survivent. Les lire dans `scope` + // 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) .Take(6) .ToListAsync(), @@ -387,7 +398,9 @@ namespace ManagerService.Controllers // 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 // 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); return Ok(result); diff --git a/ManagerService/DTOs/InstanceDTO.cs b/ManagerService/DTOs/InstanceDTO.cs index 39c843d..d034af4 100644 --- a/ManagerService/DTOs/InstanceDTO.cs +++ b/ManagerService/DTOs/InstanceDTO.cs @@ -21,6 +21,8 @@ namespace ManagerService.DTOs public string? guideName { get; set; } public string? guidePersonaPrompt { get; set; } public string? guideVoiceId { get; set; } + /// Le client autorise-t-il l'enregistrement des questions de ses visiteurs ? (RGPD, lot J) + public bool? isVisitorQuestionCollectionEnabled { get; set; } public List? guideFallbackMessages { get; set; } public string? webSlug { get; set; } diff --git a/ManagerService/Data/Instance.cs b/ManagerService/Data/Instance.cs index c7901e3..a5d58dc 100644 --- a/ManagerService/Data/Instance.cs +++ b/ManagerService/Data/Instance.cs @@ -54,6 +54,21 @@ namespace ManagerService.Data /// Voix Gemini TTS : "Sulafat" (Viva, féminine) ou "Umbriel" (Marco, masculine). public string? GuideVoiceId { get; set; } + /// + /// 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 à true : 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. + /// + public bool IsVisitorQuestionCollectionEnabled { get; set; } = true; + /// /// Formulations utilisées quand le guide ne trouve pas de réponse. /// Liste à plat : plusieurs entrées peuvent partager la même langue, @@ -128,6 +143,7 @@ namespace ManagerService.Data guideName = GuideName, guidePersonaPrompt = GuidePersonaPrompt, guideVoiceId = GuideVoiceId, + isVisitorQuestionCollectionEnabled = IsVisitorQuestionCollectionEnabled, guideFallbackMessages = GuideFallbackMessages, webSlug = WebSlug, publicApiKey = PublicApiKey, @@ -169,6 +185,8 @@ namespace ManagerService.Data GuidePersonaPrompt = instanceDTO.guidePersonaPrompt; if (instanceDTO.guideVoiceId != null) GuideVoiceId = instanceDTO.guideVoiceId; + if (instanceDTO.isVisitorQuestionCollectionEnabled != null) + IsVisitorQuestionCollectionEnabled = instanceDTO.isVisitorQuestionCollectionEnabled.Value; if (instanceDTO.guideFallbackMessages != null) GuideFallbackMessages = instanceDTO.guideFallbackMessages; if (instanceDTO.webSlug != null) diff --git a/ManagerService/Data/MyInfoMateDbContext.cs b/ManagerService/Data/MyInfoMateDbContext.cs index ac87d3a..da5b060 100644 --- a/ManagerService/Data/MyInfoMateDbContext.cs +++ b/ManagerService/Data/MyInfoMateDbContext.cs @@ -75,6 +75,7 @@ namespace ManagerService.Data // Guide IA — journal des questions visiteurs public DbSet VisitorQuestions { get; set; } + public DbSet QuestionThemeMonthlies { get; set; } public override int SaveChanges(bool acceptAllChangesOnSuccess) { diff --git a/ManagerService/Data/MyInfoMateDbContextFactory.cs b/ManagerService/Data/MyInfoMateDbContextFactory.cs new file mode 100644 index 0000000..e2c00c6 --- /dev/null +++ b/ManagerService/Data/MyInfoMateDbContextFactory.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using System; + +namespace ManagerService.Data +{ + /// + /// 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. MIGRATIONS_CONNECTION permet de la + /// surcharger pour un database update ponctuel. + /// + public class MyInfoMateDbContextFactory : IDesignTimeDbContextFactory + { + 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() + .UseNpgsql(connectionString, o => + { + o.UseNetTopologySuite(); + o.UseVector(); + }) + .Options; + + return new MyInfoMateDbContext(options, new HttpContextAccessor()); + } + } +} diff --git a/ManagerService/Data/QuestionThemeMonthly.cs b/ManagerService/Data/QuestionThemeMonthly.cs new file mode 100644 index 0000000..579478a --- /dev/null +++ b/ManagerService/Data/QuestionThemeMonthly.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore; + +namespace ManagerService.Data +{ + /// + /// 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 , 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. + /// + [Index(nameof(InstanceId), nameof(Month))] + public class QuestionThemeMonthly + { + public long Id { get; set; } + + public string InstanceId { get; set; } + + /// Premier jour du mois concerné, en UTC. La granularité est le mois, pas le jour. + public DateTime Month { get; set; } + + /// + /// Libellé du thème, pris dans . + /// + /// ⚠️ 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. + /// + public string Theme { get; set; } + + public int Count { get; set; } + } +} diff --git a/ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.Designer.cs b/ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.Designer.cs new file mode 100644 index 0000000..ac7b124 --- /dev/null +++ b/ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.Designer.cs @@ -0,0 +1,1913 @@ +// +using System; +using System.Collections.Generic; +using Manager.DTOs; +using ManagerService.DTOs; +using ManagerService.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; + +#nullable disable + +namespace ManagerService.Migrations +{ + [DbContext(typeof(MyInfoMateDbContext))] + [Migration("20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch")] + partial class LotJ_ThemeAggregatesAndCollectionSwitch + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ManagerService.Data.ApiKey", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("KeyHash") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ManagerService.Data.AppConfigurationLink", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ApplicationInstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConfigurationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceId") + .HasColumnType("text"); + + b.Property("GridColSpan") + .HasColumnType("integer"); + + b.Property("GridRowSpan") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDate") + .HasColumnType("boolean"); + + b.Property("IsHour") + .HasColumnType("boolean"); + + b.Property("IsSectionImageBackground") + .HasColumnType("boolean"); + + b.Property("LoaderImageId") + .HasColumnType("text"); + + b.Property("LoaderImageUrl") + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PrimaryColor") + .HasColumnType("text"); + + b.Property("RoundedValue") + .HasColumnType("integer"); + + b.Property("ScreenPercentageSectionsMainPage") + .HasColumnType("integer"); + + b.Property("SecondaryColor") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationInstanceId"); + + b.HasIndex("ConfigurationId"); + + b.HasIndex("DeviceId"); + + b.ToTable("AppConfigurationLinks"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsAssistant") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("Languages") + .HasColumnType("text[]"); + + b.Property("LoaderImageId") + .HasColumnType("text"); + + b.Property("LoaderImageUrl") + .HasColumnType("text"); + + b.Property("MainImageId") + .HasColumnType("text"); + + b.Property("MainImageUrl") + .HasColumnType("text"); + + b.Property("PrimaryColor") + .HasColumnType("text"); + + b.Property("SecondaryColor") + .HasColumnType("text"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("SectionEventId"); + + b.ToTable("ApplicationInstances"); + }); + + modelBuilder.Entity("ManagerService.Data.AuditLog", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Action") + .HasColumnType("text"); + + b.Property("EntityId") + .HasColumnType("text"); + + b.Property("EntityType") + .HasColumnType("text"); + + b.Property("InstanceId") + .HasColumnType("text"); + + b.Property("NewValues") + .HasColumnType("text"); + + b.Property("OldValues") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("ManagerService.Data.Configuration", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageId") + .HasColumnType("text"); + + b.Property("ImageSource") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsOffline") + .HasColumnType("boolean"); + + b.Property("IsQRCode") + .HasColumnType("boolean"); + + b.Property("IsSearchNumber") + .HasColumnType("boolean"); + + b.Property("IsSearchText") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("Languages") + .HasColumnType("text[]"); + + b.Property("LoaderImageId") + .HasColumnType("text"); + + b.Property("LoaderImageUrl") + .HasColumnType("text"); + + b.Property("PrimaryColor") + .HasColumnType("text"); + + b.Property("SecondaryColor") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("Configurations"); + }); + + modelBuilder.Entity("ManagerService.Data.ContentEmbedding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChunkIndex") + .HasColumnType("integer"); + + b.Property("ConfigurationId") + .HasColumnType("text"); + + b.Property("ContentId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .HasColumnType("integer"); + + b.Property("Embedding") + .IsRequired() + .HasColumnType("vector(768)"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("PageNumber") + .HasColumnType("integer"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + + b.HasIndex("InstanceId", "ContentType"); + + b.HasIndex("ContentType", "ContentId", "ChunkIndex") + .IsUnique(); + + b.ToTable("ContentEmbeddings"); + }); + + modelBuilder.Entity("ManagerService.Data.Device", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppVersion") + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("text"); + + b.Property("ConfigurationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Connected") + .HasColumnType("boolean"); + + b.Property("ConnectionLevel") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Identifier") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IpAddressETH") + .HasColumnType("text"); + + b.Property("IpAddressWLAN") + .HasColumnType("text"); + + b.Property("LastBatteryLevel") + .HasColumnType("timestamp with time zone"); + + b.Property("LastConnectionLevel") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeen") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConfigurationId"); + + b.HasIndex("InstanceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("ManagerService.Data.Instance", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AiTokensPerMonth") + .HasColumnType("bigint"); + + b.Property("AiTokensThisMonth") + .HasColumnType("bigint"); + + b.Property("AiUsageMonthKey") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("BillingCountry") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("GuideFallbackMessages") + .HasColumnType("jsonb"); + + b.Property("GuideName") + .HasColumnType("text"); + + b.Property("GuidePersonaPrompt") + .HasColumnType("text"); + + b.Property("GuideVoiceId") + .HasColumnType("text"); + + b.Property("HasAdvancedStats") + .HasColumnType("boolean"); + + b.Property("HasStats") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAssistant") + .HasColumnType("boolean"); + + b.Property("IsImageWatermark") + .HasColumnType("boolean"); + + b.Property("IsMobile") + .HasColumnType("boolean"); + + b.Property("IsPushNotification") + .HasColumnType("boolean"); + + b.Property("IsTablet") + .HasColumnType("boolean"); + + b.Property("IsTrialActive") + .HasColumnType("boolean"); + + b.Property("IsVR") + .HasColumnType("boolean"); + + b.Property("IsVisitorQuestionCollectionEnabled") + .HasColumnType("boolean"); + + b.Property("IsWeb") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PinCode") + .HasColumnType("text"); + + b.Property("PublicApiKey") + .HasColumnType("text"); + + b.Property("StatsHistoryDays") + .HasColumnType("integer"); + + b.Property("StorageQuotaBytes") + .HasColumnType("bigint"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionPlanId") + .HasColumnType("text"); + + b.Property("TrialAiTokensUsed") + .HasColumnType("bigint"); + + b.Property("TrialCheckInEmailSent") + .HasColumnType("boolean"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrialLastDayEmailSent") + .HasColumnType("boolean"); + + b.Property("TrialReminderEmailSent") + .HasColumnType("boolean"); + + b.Property("VatNumber") + .HasColumnType("text"); + + b.Property("VatRate") + .HasColumnType("numeric"); + + b.Property("WebSlug") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionPlanId"); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ManagerService.Data.PushNotification", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("HangfireJobId") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("PushNotifications"); + }); + + modelBuilder.Entity("ManagerService.Data.QuestionThemeMonthly", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("text"); + + b.Property("Month") + .HasColumnType("timestamp with time zone"); + + b.Property("Theme") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Month"); + + b.ToTable("QuestionThemeMonthlies"); + }); + + modelBuilder.Entity("ManagerService.Data.Resource", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AiChunkCount") + .HasColumnType("integer"); + + b.Property("AiIndexMessage") + .HasColumnType("text"); + + b.Property("AiIndexStatus") + .HasColumnType("integer"); + + b.Property("AiIndexedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("IncludeInAiKnowledge") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoragePath") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("Resources"); + }); + + modelBuilder.Entity("ManagerService.Data.Section", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BeaconId") + .HasColumnType("integer"); + + b.Property("ConfigurationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("jsonb"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ImageId") + .HasColumnType("text"); + + b.Property("ImageSource") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsBeacon") + .HasColumnType("boolean"); + + b.Property("IsSubSection") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("Latitude") + .HasColumnType("text"); + + b.Property("Longitude") + .HasColumnType("text"); + + b.Property("MeterZoneGPS") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("text"); + + b.Property("SectionMenuId") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("SectionMenuId"); + + b.ToTable("Sections"); + + b.HasDiscriminator().HasValue("Base"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.EventAgenda", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("jsonb"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone"); + + b.Property("DateFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("DateTo") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IdVideoYoutube") + .HasColumnType("text"); + + b.Property("IsSynced") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("ResourceId") + .HasColumnType("text"); + + b.Property("SectionAgendaId") + .HasColumnType("text"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("SyncedImageUrl") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("text"); + + b.Property("VideoLink") + .HasColumnType("text"); + + b.Property("VideoResourceId") + .HasColumnType("text"); + + b.Property("Website") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("SectionAgendaId"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("VideoResourceId"); + + b.ToTable("EventAgendas"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategorieId") + .HasColumnType("integer"); + + b.Property("Contents") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Email") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("ImageResourceId") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PolyColor") + .HasColumnType("text"); + + b.Property("Prices") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Schedules") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("SectionMapId") + .HasColumnType("text"); + + b.Property("Site") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("SectionMapId"); + + b.ToTable("GeoPoints"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("jsonb"); + + b.Property("EstimatedDurationMinutes") + .HasColumnType("integer"); + + b.Property("GameMessageDebut") + .HasColumnType("jsonb"); + + b.Property("GameMessageFin") + .HasColumnType("jsonb"); + + b.Property("HideNextStepsUntilComplete") + .HasColumnType("boolean"); + + b.Property("ImageResourceId") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsGameMode") + .HasColumnType("boolean"); + + b.Property("IsLinear") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("RequireSuccessToAdvance") + .HasColumnType("boolean"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("SectionParcoursId") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ImageResourceId"); + + b.HasIndex("InstanceId"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("SectionParcoursId"); + + b.ToTable("GuidedPaths"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AudioIds") + .HasColumnType("jsonb"); + + b.Property("Contents") + .HasColumnType("jsonb"); + + b.Property("Description") + .HasColumnType("jsonb"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("GuidedPathId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsGeoTriggered") + .HasColumnType("boolean"); + + b.Property("IsStepTimer") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("TimerExpiredMessage") + .HasColumnType("jsonb"); + + b.Property("TimerSeconds") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ZoneRadiusMeters") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("GuidedPathId"); + + b.ToTable("GuidedSteps"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.QuizQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GuidedStepId") + .HasColumnType("text"); + + b.Property("IsSlidingPuzzle") + .HasColumnType("boolean"); + + b.Property>("Label") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PuzzleCols") + .HasColumnType("integer"); + + b.Property("PuzzleImageId") + .HasColumnType("text"); + + b.Property("PuzzleRows") + .HasColumnType("integer"); + + b.Property("ResourceId") + .HasColumnType("text"); + + b.Property>("Responses") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SectionQuizId") + .HasColumnType("text"); + + b.Property("ValidationQuestionType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GuidedStepId"); + + b.HasIndex("PuzzleImageId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("SectionQuizId"); + + b.ToTable("QuizQuestions"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+MapAnnotation", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("GeometryType") + .HasColumnType("integer"); + + b.Property("Icon") + .HasColumnType("text"); + + b.Property("IconResourceId") + .HasColumnType("text"); + + b.Property>("Label") + .HasColumnType("jsonb"); + + b.Property("PolyColor") + .HasColumnType("text"); + + b.Property("ProgrammeBlockId") + .HasColumnType("text"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property>("Type") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("IconResourceId"); + + b.HasIndex("ProgrammeBlockId"); + + b.HasIndex("SectionEventId"); + + b.ToTable("MapAnnotations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property>("Description") + .HasColumnType("jsonb"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property>("Title") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.ToTable("ProgrammeBlocks"); + }); + + modelBuilder.Entity("ManagerService.Data.SubscriptionPlan", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AiTokensPerMonth") + .HasColumnType("bigint"); + + b.Property("HasAdvancedStats") + .HasColumnType("boolean"); + + b.Property("HasStats") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatsHistoryDays") + .HasColumnType("integer"); + + b.Property("StorageQuotaBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionPlans"); + + b.HasData( + new + { + Id = "plan-essentiel", + AiTokensPerMonth = 0L, + HasAdvancedStats = false, + HasStats = true, + Name = "Essentiel", + StatsHistoryDays = 30, + StorageQuotaBytes = 1073741824L + }, + new + { + Id = "plan-pro", + AiTokensPerMonth = 0L, + HasAdvancedStats = false, + HasStats = true, + Name = "Pro", + StatsHistoryDays = 30, + StorageQuotaBytes = 16106127360L + }, + new + { + Id = "plan-premium", + AiTokensPerMonth = 20000000L, + HasAdvancedStats = true, + HasStats = true, + Name = "Premium", + StatsHistoryDays = 395, + StorageQuotaBytes = 53687091200L + }, + new + { + Id = "plan-enterprise", + AiTokensPerMonth = 9223372036854775807L, + HasAdvancedStats = true, + HasStats = true, + Name = "Enterprise", + StatsHistoryDays = 395, + StorageQuotaBytes = 0L + }); + }); + + modelBuilder.Entity("ManagerService.Data.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordTokenHash") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ManagerService.Data.VisitEvent", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("ConfigurationId") + .HasColumnType("text"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("SectionId") + .HasColumnType("text"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("Timestamp"); + + b.ToTable("VisitEvents"); + }); + + modelBuilder.Entity("ManagerService.Data.VisitorQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("CitedContentIds") + .HasColumnType("jsonb"); + + b.Property("ConfigurationId") + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasAnswer") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("text"); + + b.Property("IsVoice") + .HasColumnType("boolean"); + + b.Property("Language") + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("Question") + .HasColumnType("text"); + + b.Property("Reply") + .HasColumnType("text"); + + b.Property("ThemeId") + .HasColumnType("text"); + + b.Property("TokensUsed") + .HasColumnType("bigint"); + + b.Property("TopScore") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("InstanceId", "CreatedAt"); + + b.ToTable("VisitorQuestions"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("AgendaMapProvider") + .HasColumnType("integer"); + + b.Property>("AgendaResourceIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IsOnlineAgenda") + .HasColumnType("boolean"); + + b.HasDiscriminator().HasValue("Agenda"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionArticle", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("ArticleAudioIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("ArticleContent") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("ArticleContents") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ArticleIsContentTop") + .HasColumnType("boolean"); + + b.Property("ArticleIsReadAudioAuto") + .HasColumnType("boolean"); + + b.HasDiscriminator().HasValue("Article"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("BaseSectionMapId") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("BaseSectionMapId"); + + b.HasDiscriminator().HasValue("Event"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("GameMessageDebut") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("GameMessageFin") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("GamePuzzleCols") + .HasColumnType("integer"); + + b.Property("GamePuzzleImageId") + .HasColumnType("text"); + + b.Property("GamePuzzleRows") + .HasColumnType("integer"); + + b.Property("GameType") + .HasColumnType("integer"); + + b.HasIndex("GamePuzzleImageId"); + + b.HasDiscriminator().HasValue("Game"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("IconResourceId") + .HasColumnType("text"); + + b.Property("IsListViewEnabled") + .HasColumnType("boolean"); + + b.Property>("MapCategories") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MapCenterLatitude") + .HasColumnType("text"); + + b.Property("MapCenterLongitude") + .HasColumnType("text"); + + b.Property("MapMapProvider") + .HasColumnType("integer"); + + b.Property("MapMapType") + .HasColumnType("integer"); + + b.Property("MapTypeMapbox") + .HasColumnType("integer"); + + b.Property("MapZoom") + .HasColumnType("integer"); + + b.HasIndex("IconResourceId"); + + b.HasDiscriminator().HasValue("Map"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMenu", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.HasDiscriminator().HasValue("Menu"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionParcours", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("BaseSectionMapId") + .HasColumnType("text"); + + b.Property("ShowMap") + .HasColumnType("boolean"); + + b.HasIndex("BaseSectionMapId"); + + b.ToTable("Sections", t => + { + t.Property("BaseSectionMapId") + .HasColumnName("SectionParcours_BaseSectionMapId"); + }); + + b.HasDiscriminator().HasValue("Parcours"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionPdf", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("PDFOrderedTranslationAndResources") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("PDF"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionQuiz", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("QuizBadLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizGoodLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizGreatLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizMediumLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("Quiz"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionSlider", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("SliderContents") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("Slider"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionVideo", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("VideoSource") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Video"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionWeather", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("WeatherCity") + .HasColumnType("text"); + + b.Property("WeatherResult") + .HasColumnType("text"); + + b.Property("WeatherUpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasDiscriminator().HasValue("Weather"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionWeb", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("WebSource") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Web"); + }); + + modelBuilder.Entity("ManagerService.Data.ApiKey", b => + { + b.HasOne("ManagerService.Data.Instance", "Instance") + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("ManagerService.Data.AppConfigurationLink", b => + { + b.HasOne("ManagerService.Data.ApplicationInstance", "ApplicationInstance") + .WithMany("Configurations") + .HasForeignKey("ApplicationInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ManagerService.Data.Configuration", "Configuration") + .WithMany() + .HasForeignKey("ConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ManagerService.Data.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId"); + + b.Navigation("ApplicationInstance"); + + b.Navigation("Configuration"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.Navigation("SectionEvent"); + }); + + modelBuilder.Entity("ManagerService.Data.Device", b => + { + b.HasOne("ManagerService.Data.Configuration", "Configuration") + .WithMany() + .HasForeignKey("ConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Configuration"); + }); + + modelBuilder.Entity("ManagerService.Data.Instance", b => + { + b.HasOne("ManagerService.Data.SubscriptionPlan", "SubscriptionPlan") + .WithMany() + .HasForeignKey("SubscriptionPlanId"); + + b.Navigation("SubscriptionPlan"); + }); + + modelBuilder.Entity("ManagerService.Data.Section", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMenu", null) + .WithMany("MenuSections") + .HasForeignKey("SectionMenuId"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.EventAgenda", b => + { + b.HasOne("ManagerService.Data.Resource", "Resource") + .WithMany() + .HasForeignKey("ResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionAgenda", "SectionAgenda") + .WithMany("EventAgendas") + .HasForeignKey("SectionAgendaId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.Resource", "VideoResource") + .WithMany() + .HasForeignKey("VideoResourceId"); + + b.Navigation("Resource"); + + b.Navigation("SectionAgenda"); + + b.Navigation("SectionEvent"); + + b.Navigation("VideoResource"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.SubSection.SectionMap", "SectionMap") + .WithMany("MapPoints") + .HasForeignKey("SectionMapId"); + + b.Navigation("SectionEvent"); + + b.Navigation("SectionMap"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.HasOne("ManagerService.Data.Resource", "ImageResource") + .WithMany() + .HasForeignKey("ImageResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.SubSection.SectionParcours", "SectionParcours") + .WithMany("GuidedPaths") + .HasForeignKey("SectionParcoursId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ImageResource"); + + b.Navigation("SectionEvent"); + + b.Navigation("SectionParcours"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.HasOne("ManagerService.Data.SubSection.GuidedPath", "GuidedPath") + .WithMany("Steps") + .HasForeignKey("GuidedPathId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GuidedPath"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.QuizQuestion", b => + { + b.HasOne("ManagerService.Data.SubSection.GuidedStep", "GuidedStep") + .WithMany("QuizQuestions") + .HasForeignKey("GuidedStepId"); + + b.HasOne("ManagerService.Data.Resource", "PuzzleImage") + .WithMany() + .HasForeignKey("PuzzleImageId"); + + b.HasOne("ManagerService.Data.Resource", "Resource") + .WithMany() + .HasForeignKey("ResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionQuiz", "SectionQuiz") + .WithMany("QuizQuestions") + .HasForeignKey("SectionQuizId"); + + b.Navigation("GuidedStep"); + + b.Navigation("PuzzleImage"); + + b.Navigation("Resource"); + + b.Navigation("SectionQuiz"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+MapAnnotation", b => + { + b.HasOne("ManagerService.Data.Resource", "IconResource") + .WithMany() + .HasForeignKey("IconResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", null) + .WithMany("MapAnnotations") + .HasForeignKey("ProgrammeBlockId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent", null) + .WithMany("GlobalMapAnnotations") + .HasForeignKey("SectionEventId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("IconResource"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", null) + .WithMany("Programme") + .HasForeignKey("SectionEventId"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMap", "BaseMap") + .WithMany() + .HasForeignKey("BaseSectionMapId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseMap"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b => + { + b.HasOne("ManagerService.Data.Resource", "GamePuzzleImage") + .WithMany() + .HasForeignKey("GamePuzzleImageId"); + + b.Navigation("GamePuzzleImage"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.HasOne("ManagerService.Data.Resource", "IconResource") + .WithMany() + .HasForeignKey("IconResourceId"); + + b.Navigation("IconResource"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionParcours", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMap", "BaseMap") + .WithMany() + .HasForeignKey("BaseSectionMapId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseMap"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.Navigation("Configurations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.Navigation("QuizQuestions"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.Navigation("MapAnnotations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b => + { + b.Navigation("EventAgendas"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.Navigation("GlobalMapAnnotations"); + + b.Navigation("Programme"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.Navigation("MapPoints"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMenu", b => + { + b.Navigation("MenuSections"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionParcours", b => + { + b.Navigation("GuidedPaths"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionQuiz", b => + { + b.Navigation("QuizQuestions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.cs b/ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.cs new file mode 100644 index 0000000..2961099 --- /dev/null +++ b/ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ManagerService.Migrations +{ + /// + public partial class LotJ_ThemeAggregatesAndCollectionSwitch : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsVisitorQuestionCollectionEnabled", + table: "Instances", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "QuestionThemeMonthlies", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + InstanceId = table.Column(type: "text", nullable: true), + Month = table.Column(type: "timestamp with time zone", nullable: false), + Theme = table.Column(type: "text", nullable: true), + Count = table.Column(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" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "QuestionThemeMonthlies"); + + migrationBuilder.DropColumn( + name: "IsVisitorQuestionCollectionEnabled", + table: "Instances"); + } + } +} diff --git a/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs b/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs index ed6164c..8a8659f 100644 --- a/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs +++ b/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs @@ -483,6 +483,9 @@ namespace ManagerService.Migrations b.Property("IsVR") .HasColumnType("boolean"); + b.Property("IsVisitorQuestionCollectionEnabled") + .HasColumnType("boolean"); + b.Property("IsWeb") .HasColumnType("boolean"); @@ -585,6 +588,33 @@ namespace ManagerService.Migrations b.ToTable("PushNotifications"); }); + modelBuilder.Entity("ManagerService.Data.QuestionThemeMonthly", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("text"); + + b.Property("Month") + .HasColumnType("timestamp with time zone"); + + b.Property("Theme") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Month"); + + b.ToTable("QuestionThemeMonthlies"); + }); + modelBuilder.Entity("ManagerService.Data.Resource", b => { b.Property("Id") diff --git a/ManagerService/Services/QuestionThemes.cs b/ManagerService/Services/QuestionThemes.cs new file mode 100644 index 0000000..a5f7db3 --- /dev/null +++ b/ManagerService/Services/QuestionThemes.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; + +namespace ManagerService.Services +{ + /// + /// 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 , donc le renommer coupe l'historique + /// en deux. Un nouveau thème, lui, apparaît simplement à partir du mois où il est ajouté. + /// + public static class QuestionThemes + { + public const string Other = "Autre"; + + public static readonly IReadOnlyList 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, + }; + + /// + /// 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. + /// + 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; + } + } +} diff --git a/ManagerService/Services/QuestionThemingService.cs b/ManagerService/Services/QuestionThemingService.cs new file mode 100644 index 0000000..bb12f29 --- /dev/null +++ b/ManagerService/Services/QuestionThemingService.cs @@ -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 +{ + /// + /// 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(); + } + } +} diff --git a/ManagerService/Startup.cs b/ManagerService/Startup.cs index 92ef10f..17e11a5 100644 --- a/ManagerService/Startup.cs +++ b/ManagerService/Startup.cs @@ -216,6 +216,7 @@ namespace ManagerService services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); var connectionString = Configuration.GetConnectionString("PostgresConnection"); @@ -378,6 +379,14 @@ namespace ManagerService s => s.PurgeAsync(), "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( + "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. RecurringJob.AddOrUpdate( "visitor-questions-purge",