From 18e4240f0f5de6b370a29770243f9743cd89bd1f Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Tue, 11 Aug 2026 10:48:10 +0200 Subject: [PATCH] RAG: pipeline d'ingestion, endpoints du guide IA, journalisation RGPD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ingestion et indexation - IIngestionService/IngestionService : chargement des collections filles par sous-type, un jeu de morceaux par langue, ChunkIndex continu. - SectionIndexingInterceptor retenu comme unique déclencheur : les 5 sous-contrôleurs totalisaient 30 SaveChanges et 0 Enqueue, donc ajouter des points d'intérêt à une carte ne réindexait rien. - HTML retiré avant l'embedding et lignes trop longues recoupées : sans cela un article dépassait l'entrée max du modèle et emportait son lot de 50 morceaux. - Gabarits de LanguageInit filtrés, DistinctBy(Text) avant le Take : ils occupaient les cinq premiers résultats d'une recherche en néerlandais. Endpoints du guide IA - GET /api/Ai/knowledge/{id} : agrégats sur ContentEmbedding, donc sur ce qui est réellement indexé — compter les sections publiées serait plus flatteur et faux. - GET /api/Ai/insights/{id} : miroir de GuideIaInsights côté manager-app, c'est l'écran qui a fixé la forme pour que le job de thèmes la remplisse. RGPD - VisitorQuestion journalisée dans AiController.Chat. HasAnswer se déduit des sources du retrieval, pas du texte : un repli poli ressemble à une réponse. L'écriture n'échoue jamais la réponse au visiteur. - VisitorQuestionPurgeService, 90 jours, actif sans condition de configuration : une durée écrite dans les CGU n'est pas un réglage commercial. Corrections - Updateinstance ne recopiait pas les quotas du nouveau plan. - CheckQuota ne bloquait ni ne comptait à quota 0 — IA gratuite non comptée. - StoragePath et SizeBytes renseignés à Create, types URL exclus. dotnet build 0 erreur, dotnet test 130/130. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 + .../Controllers/AiControllerTests.cs | 38 ++- .../Controllers/InstanceControllerTests.cs | 64 ++++- .../Controllers/ResourceControllerTests.cs | 45 +++- .../Controllers/SectionControllerTests.cs | 6 +- .../Infrastructure/TestSection.cs | 29 +++ ManagerService/Controllers/AiController.cs | 225 ++++++++++++++++- .../Controllers/ConfigurationController.cs | 2 +- .../Controllers/InstanceController.cs | 65 +++-- .../Controllers/ResourceController.cs | 10 + .../Controllers/SectionController.cs | 110 ++------ ManagerService/DTOs/AiChatDTO.cs | 18 ++ ManagerService/DTOs/GuideInsightsDTO.cs | 32 +++ ManagerService/DTOs/GuideKnowledgeDTO.cs | 19 ++ ManagerService/Data/Section.cs | 32 ++- .../Data/SectionIndexingInterceptor.cs | 200 +++++++++++++++ ManagerService/Data/SectionText.cs | 51 ++++ ManagerService/Data/SubSection/EventAgenda.cs | 13 + ManagerService/Data/SubSection/GuidedPath.cs | 20 ++ ManagerService/Data/SubSection/GuidedStep.cs | 21 ++ .../Data/SubSection/QuizQuestion.cs | 7 + .../Data/SubSection/SectionAgenda.cs | 14 ++ .../Data/SubSection/SectionArticle.cs | 13 + .../Data/SubSection/SectionEvent.cs | 27 ++ ManagerService/Data/SubSection/SectionGame.cs | 13 + ManagerService/Data/SubSection/SectionMap.cs | 32 +++ ManagerService/Data/SubSection/SectionMenu.cs | 8 + .../Data/SubSection/SectionParcours.cs | 16 +- ManagerService/Data/SubSection/SectionPdf.cs | 16 ++ ManagerService/Data/SubSection/SectionQuiz.cs | 21 ++ .../Data/SubSection/SectionSlider.cs | 8 + .../Data/SubSection/SectionVideo.cs | 14 ++ .../Data/SubSection/SectionWeather.cs | 9 + ManagerService/Data/SubSection/SectionWeb.cs | 8 + ManagerService/Services/AgendaSyncService.cs | 3 + ManagerService/Services/AssistantService.cs | 79 +++++- ManagerService/Services/IIngestionService.cs | 22 ++ .../Services/IVectorStoreService.cs | 48 ++++ ManagerService/Services/IngestionService.cs | 236 ++++++++++++++++++ ManagerService/Services/SectionFactory.cs | 48 ++++ ManagerService/Services/VectorStoreService.cs | 158 ++++++++++++ .../Services/VisitorQuestionPurgeService.cs | 65 +++++ ManagerService/Startup.cs | 30 ++- ManagerService/appsettings.json | 3 +- 44 files changed, 1768 insertions(+), 134 deletions(-) create mode 100644 ManagerService.Tests/Infrastructure/TestSection.cs create mode 100644 ManagerService/DTOs/GuideInsightsDTO.cs create mode 100644 ManagerService/DTOs/GuideKnowledgeDTO.cs create mode 100644 ManagerService/Data/SectionIndexingInterceptor.cs create mode 100644 ManagerService/Data/SectionText.cs create mode 100644 ManagerService/Services/IIngestionService.cs create mode 100644 ManagerService/Services/IVectorStoreService.cs create mode 100644 ManagerService/Services/IngestionService.cs create mode 100644 ManagerService/Services/VectorStoreService.cs create mode 100644 ManagerService/Services/VisitorQuestionPurgeService.cs diff --git a/.gitignore b/.gitignore index ce02ed0..8a3e9d9 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,7 @@ ManagerService/obj # MongoDB export data (données sensibles — ne jamais committer) migration-data/ + +# Dumps PostgreSQL (données clients — ne jamais committer) +*.dump +*.sql.gz diff --git a/ManagerService.Tests/Controllers/AiControllerTests.cs b/ManagerService.Tests/Controllers/AiControllerTests.cs index 24686b8..781700b 100644 --- a/ManagerService.Tests/Controllers/AiControllerTests.cs +++ b/ManagerService.Tests/Controllers/AiControllerTests.cs @@ -5,6 +5,7 @@ using ManagerService.Services; using ManagerService.Tests.Infrastructure; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; +using Hangfire; using Moq; using System; using System.Collections.Generic; @@ -28,7 +29,8 @@ namespace ManagerService.Tests.Controllers .Setup(s => s.ChatAsync(It.IsAny())) .ReturnsAsync(FakeResponse); - var controller = new AiController(mockService.Object, db, NullLogger.Instance); + var controller = new AiController(mockService.Object, db, NullLogger.Instance, + new Mock().Object); FakeUser.SetUser(controller, FakeUser.Create(callerRole, callerInstanceId)); return controller; } @@ -112,7 +114,7 @@ namespace ManagerService.Tests.Controllers db.Instances.Add(new Instance { Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow, - AiTokensThisMonth = 99, AiUsageMonthKey = "2020-01" + AiTokensThisMonth = 99, AiTokensPerMonth = 1_000, AiUsageMonthKey = "2020-01" }); db.ApplicationInstances.Add(new ApplicationInstance { @@ -136,7 +138,7 @@ namespace ManagerService.Tests.Controllers db.Instances.Add(new Instance { Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow, - AiTokensThisMonth = 3, AiUsageMonthKey = monthKey + AiTokensThisMonth = 3, AiTokensPerMonth = 1_000, AiUsageMonthKey = monthKey }); db.ApplicationInstances.Add(new ApplicationInstance { @@ -177,6 +179,34 @@ namespace ManagerService.Tests.Controllers mockService.Verify(s => s.ChatAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task Chat_PlanWithoutAi_ReturnsForbiddenWithoutCallingAssistantService() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance + { + // AiTokensPerMonth à 0 = pas d'IA dans le plan, et surtout pas « illimité » : + // c'est l'état de plan-starter et de toute instance fraîchement migrée. + Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow, + AiTokensPerMonth = 0, AiUsageMonthKey = DateTime.UtcNow.ToString("yyyy-MM") + }); + db.ApplicationInstances.Add(new ApplicationInstance + { + Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = true, + Languages = new List() + }); + db.SaveChanges(); + + var mockService = new Mock(); + mockService.Setup(s => s.ChatAsync(It.IsAny())).ReturnsAsync(FakeResponse); + + var result = await BuildController(db, mockService).Chat(MakeRequest("i1")); + + var status = Assert.IsType(result); + Assert.Equal(403, status.StatusCode); + mockService.Verify(s => s.ChatAsync(It.IsAny()), Times.Never); + } + // ── NOMINAL ────────────────────────────────────────────────────────── [Fact] @@ -189,7 +219,7 @@ namespace ManagerService.Tests.Controllers db.Instances.Add(new Instance { Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow, - AiUsageMonthKey = DateTime.UtcNow.ToString("yyyy-MM") + AiTokensPerMonth = 1_000, AiUsageMonthKey = DateTime.UtcNow.ToString("yyyy-MM") }); db.ApplicationInstances.Add(new ApplicationInstance { diff --git a/ManagerService.Tests/Controllers/InstanceControllerTests.cs b/ManagerService.Tests/Controllers/InstanceControllerTests.cs index 831cca1..f666b64 100644 --- a/ManagerService.Tests/Controllers/InstanceControllerTests.cs +++ b/ManagerService.Tests/Controllers/InstanceControllerTests.cs @@ -1,4 +1,6 @@ +using Hangfire; using Manager.Services; +using Manager.Interfaces.Models; using ManagerService.Controllers; using ManagerService.Data; using ManagerService.DTOs; @@ -7,13 +9,14 @@ using ManagerService.Services; using ManagerService.Tests.Infrastructure; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; +using Moq; using Xunit; namespace ManagerService.Tests.Controllers { public class InstanceControllerTests { - private InstanceController BuildController(MyInfoMateDbContext db) + private InstanceController BuildController(MyInfoMateDbContext db, Mock jobs = null) { var cfg = FakeMongoConfig.Create(); var instanceService = new InstanceDatabaseService(cfg); @@ -27,7 +30,8 @@ namespace ManagerService.Tests.Controllers userService, profileLogic, db, - apiKeyService); + apiKeyService, + (jobs ?? new Mock()).Object); } // ── CREATE ─────────────────────────────────────────────────────────── @@ -96,6 +100,62 @@ namespace ManagerService.Tests.Controllers Assert.Equal("p1", db.Instances.First().SubscriptionPlanId); } + [Fact] + public void Updateinstance_ChangingPlan_CopiesPlanQuotasOntoInstance() + { + using var db = DbContextFactory.Create(); + db.SubscriptionPlans.Add(new SubscriptionPlan + { + Id = "p-premium", Name = "Premium", + StorageQuotaBytes = 50_000, AiTokensPerMonth = 20_000, HasStats = true, HasAdvancedStats = true + }); + db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow }); + db.SaveChanges(); + + BuildController(db).Updateinstance(new InstanceDTO { id = "i1", subscriptionPlanId = "p-premium" }); + + // CreateInstance recopiait les quotas du plan, pas Update : un client passé à un plan + // payant gardait 0 jeton IA, donc pas de guide, tout en payant pour. + var instance = db.Instances.First(); + Assert.Equal(20_000, instance.AiTokensPerMonth); + Assert.Equal(50_000, instance.StorageQuotaBytes); + Assert.True(instance.HasAdvancedStats); + } + + [Fact] + public void Updateinstance_GainingAiQuota_EnqueuesBackfill() + { + using var db = DbContextFactory.Create(); + db.SubscriptionPlans.Add(new SubscriptionPlan { Id = "p-premium", Name = "Premium", AiTokensPerMonth = 20_000 }); + db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow, AiTokensPerMonth = 0 }); + db.SaveChanges(); + + var jobs = new Mock(); + BuildController(db, jobs).Updateinstance(new InstanceDTO { id = "i1", subscriptionPlanId = "p-premium" }); + + // Rien n'a été indexé tant que l'instance n'avait pas droit à l'IA : sans ce + // rattrapage le client paie un guide qui ne connaît rien de son contenu. + jobs.Verify(j => j.Create(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void Updateinstance_PlanUnchanged_DoesNotEnqueueBackfill() + { + using var db = DbContextFactory.Create(); + db.SubscriptionPlans.Add(new SubscriptionPlan { Id = "p-premium", Name = "Premium", AiTokensPerMonth = 20_000 }); + db.Instances.Add(new Instance + { + Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow, + SubscriptionPlanId = "p-premium", AiTokensPerMonth = 20_000 + }); + db.SaveChanges(); + + var jobs = new Mock(); + BuildController(db, jobs).Updateinstance(new InstanceDTO { id = "i1", name = "Musée d'Ixelles" }); + + jobs.Verify(j => j.Create(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public void Updateinstance_UnknownId_Returns404() { diff --git a/ManagerService.Tests/Controllers/ResourceControllerTests.cs b/ManagerService.Tests/Controllers/ResourceControllerTests.cs index 82ef0fd..c66b1f3 100644 --- a/ManagerService.Tests/Controllers/ResourceControllerTests.cs +++ b/ManagerService.Tests/Controllers/ResourceControllerTests.cs @@ -63,6 +63,47 @@ namespace ManagerService.Tests.Controllers Assert.Single(resources); } + // ── CREATE ─────────────────────────────────────────────────────────── + + [Fact] + public void Create_FileType_SetsStoragePathAndSizeBytes() + { + using var db = DbContextFactory.Create(); + + var result = BuildController(db).Create(new ResourceDTO + { + instanceId = "inst-test", + label = "brochure", + type = ResourceType.PDF, + sizeBytes = 12345 + }); + + Assert.IsType(result); + var created = db.Resources.First(); + Assert.Equal($"pictures/inst-test/{created.Id}", created.StoragePath); + Assert.Equal(12345, created.SizeBytes); + } + + [Fact] + public void Create_UrlType_LeavesStoragePathAndSizeBytesEmpty() + { + using var db = DbContextFactory.Create(); + + var result = BuildController(db).Create(new ResourceDTO + { + instanceId = "inst-test", + label = "wikipedia", + type = ResourceType.ImageUrl, + url = "https://example.org/photo.jpg", + sizeBytes = 12345 + }); + + Assert.IsType(result); + var created = db.Resources.First(); + Assert.Null(created.StoragePath); + Assert.Equal(0, created.SizeBytes); + } + // ── UPDATE ─────────────────────────────────────────────────────────── [Fact] @@ -109,7 +150,9 @@ namespace ManagerService.Tests.Controllers using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image }); db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", Title = new List() }); - db.Sections.Add(new Section { Id = "s1", InstanceId = "inst-test", Label = "S", ImageId = "r1", Type = SectionType.Article, ConfigurationId = "c1", Title = new List(), Description = new List() }); + var section = TestSection.Article("s1", "inst-test", "S", "c1"); + section.ImageId = "r1"; + db.Sections.Add(section); db.SaveChanges(); BuildController(db).Delete("r1"); diff --git a/ManagerService.Tests/Controllers/SectionControllerTests.cs b/ManagerService.Tests/Controllers/SectionControllerTests.cs index 466d116..a9b9b8d 100644 --- a/ManagerService.Tests/Controllers/SectionControllerTests.cs +++ b/ManagerService.Tests/Controllers/SectionControllerTests.cs @@ -45,8 +45,8 @@ namespace ManagerService.Tests.Controllers { using var db = DbContextFactory.Create(); db.Sections.AddRange( - new Section { Id = "s1", InstanceId = "inst-test", Label = "A", Type = SectionType.Article, ConfigurationId = "c1", Title = new List(), Description = new List() }, - new Section { Id = "s2", InstanceId = "other-inst", Label = "B", Type = SectionType.Article, ConfigurationId = "c2", Title = new List(), Description = new List() } + TestSection.Article("s1", "inst-test", "A", "c1"), + TestSection.Article("s2", "other-inst", "B", "c2") ); db.SaveChanges(); @@ -102,7 +102,7 @@ namespace ManagerService.Tests.Controllers public void Delete_ExistingSection_Returns202() { using var db = DbContextFactory.Create(); - db.Sections.Add(new Section { Id = "s1", InstanceId = "inst-test", Label = "A", Type = SectionType.Article, ConfigurationId = "c1", Title = new List(), Description = new List() }); + db.Sections.Add(TestSection.Article("s1", "inst-test", "A", "c1")); db.SaveChanges(); var result = BuildController(db).Delete("s1"); diff --git a/ManagerService.Tests/Infrastructure/TestSection.cs b/ManagerService.Tests/Infrastructure/TestSection.cs new file mode 100644 index 0000000..0694a34 --- /dev/null +++ b/ManagerService.Tests/Infrastructure/TestSection.cs @@ -0,0 +1,29 @@ +using Manager.DTOs; +using ManagerService.Data.SubSection; +using ManagerService.DTOs; +using System.Collections.Generic; + +namespace ManagerService.Tests.Infrastructure +{ + /// + /// Section concrète minimale pour les tests qui ne s'intéressent qu'aux champs de base. + /// Section est abstraite depuis l'ajout de GetEmbeddableText / GetReferencedResourceIds. + /// + public static class TestSection + { + public static SectionArticle Article(string id, string instanceId, string label, string configurationId) => + new SectionArticle + { + Id = id, + InstanceId = instanceId, + Label = label, + ConfigurationId = configurationId, + Type = SectionType.Article, + Title = new List(), + Description = new List(), + ArticleContent = new List(), + ArticleAudioIds = new List(), + ArticleContents = new List() + }; + } +} diff --git a/ManagerService/Controllers/AiController.cs b/ManagerService/Controllers/AiController.cs index b2ab976..eaddf10 100644 --- a/ManagerService/Controllers/AiController.cs +++ b/ManagerService/Controllers/AiController.cs @@ -1,3 +1,4 @@ +using Hangfire; using ManagerService.Data; using ManagerService.DTOs; using ManagerService.Services; @@ -7,6 +8,7 @@ using Microsoft.Extensions.Logging; using NSwag.Annotations; using Microsoft.EntityFrameworkCore; using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -27,15 +29,20 @@ namespace ManagerService.Controllers private readonly IAssistantService _assistantService; private readonly MyInfoMateDbContext _context; private readonly ILogger _logger; + // Injecté plutôt qu'appelé via la façade statique BackgroundJob : celle-ci lève + // sans JobStorage.Current, donc dans tout test qui touche cet endpoint. + private readonly IBackgroundJobClient _jobs; public AiController( IAssistantService assistantService, MyInfoMateDbContext context, - ILogger logger) + ILogger logger, + IBackgroundJobClient jobs) { _assistantService = assistantService; _context = context; _logger = logger; + _jobs = jobs; } private string? GetCallerInstanceId() => @@ -60,7 +67,15 @@ namespace ManagerService.Controllers } var quota = instance.AiTokensPerMonth; - if (quota > 0 && instance.AiTokensThisMonth >= quota) + + // 0 ne veut pas dire « illimité » ici — contrairement à StorageQuotaBytes, où 0 lève + // la limite. C'est « pas d'IA dans ce plan » : plan-starter est à 0, et une instance + // migrée depuis Mongo l'est aussi tant que son plan n'est pas repris (§1quinquies, c). + // Sans ce test, une telle instance avec IsAssistant à true consommait sans compteur. + if (quota <= 0) + return StatusCode(403, "L'assistant IA n'est pas inclus dans ce plan"); + + if (instance.AiTokensThisMonth >= quota) return StatusCode(429, "Quota IA mensuel dépassé"); if (instance.IsTrialActive && instance.TrialAiTokensUsed >= TrialAiTokensCap) @@ -77,6 +92,211 @@ namespace ManagerService.Controllers _context.SaveChanges(); } + /// + /// Journalise un tour de conversation. Ne fait jamais échouer la réponse au visiteur : + /// un incident de journalisation ne doit pas coûter un échange déjà payé au modèle. + /// + /// + /// ⚠️ **RGPD — le volet CGU doit être en ligne avant la mise en service.** Cette méthode + /// enregistre du texte libre saisi par un visiteur, qui peut contenir des données + /// personnelles voire sensibles (« je suis en fauteuil, c'est accessible ? »). Rien de + /// nominatif n'est stocké — ConversationId est un GUID de session — et les + /// questions brutes se purgent à 90 jours, mais l'information doit être donnée. + /// + /// HasAnswer se déduit des sources : le guide n'a rien trouvé quand la recherche + /// n'a rien remonté. C'est la colonne qui produit le rapport de trous de contenu, donc + /// l'argument de vente de tout l'onglet — la déduire du texte de la réponse serait + /// fragile, un repli poli ressemblant à une vraie réponse. + /// + private void RecordVisitorQuestion(AiChatRequest request, AiChatResponse result) + { + try + { + var sources = result.Sources ?? new List(); + + _context.VisitorQuestions.Add(new VisitorQuestion + { + ConversationId = string.IsNullOrWhiteSpace(request.ConversationId) + ? Guid.NewGuid().ToString() + : request.ConversationId, + InstanceId = request.InstanceId, + ConfigurationId = request.ConfigurationId, + AppType = request.AppType, + IsVoice = request.IsVoice, + Language = request.Language, + Question = request.Message, + Reply = result.Reply, + TokensUsed = result.TokensUsed, + HasAnswer = sources.Count > 0, + CitedContentIds = sources.Select(s => s.ContentId).Distinct().ToList(), + CreatedAt = DateTime.UtcNow + }); + _context.SaveChanges(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Journalisation de la question visiteur impossible"); + } + } + + /// + /// Relance l'indexation complète du contenu d'une instance pour le guide IA. + /// + /// + /// Réservé au SuperAdmin, volontairement. C'est un outil de réparation, pas une + /// fonctionnalité : exposé au client, il serait cliqué à chaque réponse décevante du + /// guide — alors qu'une mauvaise réponse vient presque toujours d'un contenu trop + /// maigre, pas d'un index périmé. Chaque relance recoûte un embedding par morceau de + /// toute l'instance, et c'est un moyen trivial de brûler le budget. + /// Le rattrapage normal est automatique au passage à un plan avec IA (InstanceController). + /// + [HttpPost("reindex/{instanceId}")] + [Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)] + [ProducesResponseType(typeof(object), 202)] + [ProducesResponseType(typeof(string), 403)] + [ProducesResponseType(typeof(string), 404)] + public ObjectResult Reindex(string instanceId) + { + var instance = _context.Instances.FirstOrDefault(i => i.Id == instanceId); + if (instance == null) + return new NotFoundObjectResult("Instance inconnue"); + + // Le job s'arrêterait de toute façon sur la même garde — autant le dire tout de suite. + if (instance.AiTokensPerMonth <= 0) + return new ObjectResult("L'assistant IA n'est pas inclus dans le plan de cette instance") { StatusCode = 403 }; + + var sectionCount = _context.Sections.Count(s => s.InstanceId == instanceId); + var jobId = _jobs.Enqueue(s => s.BackfillInstanceAsync(instanceId)); + + // Le nombre de morceaux n'est connu qu'à l'exécution : il part dans les logs et dans + // /hangfire. On rend ici de quoi savoir si la relance avait la moindre matière. + return new ObjectResult(new { jobId, sectionsQueued = sectionCount }) { StatusCode = 202 }; + } + + /// + /// Ce que le guide connaît réellement d'une instance, mesuré sur l'index vectoriel. + /// + /// + /// Tout vient de ContentEmbedding : ce sont les morceaux réellement indexés, donc + /// réellement interrogeables. Compter les sections dans Sections donnerait un chiffre + /// plus flatteur et faux — une section désactivée est purgée de l'index, une section sans + /// texte exploitable n'y entre jamais. + /// + /// Le nombre de morceaux remplace les « points d'intérêt » de la maquette : les points d'une + /// carte sont indexés dans le texte de leur SectionMap, pas comme des contenus autonomes. + /// Les compter dans leur propre table répondrait « combien en avez-vous », pas « qu'est-ce + /// que le guide en sait » — et afficherait des points appartenant à une section non indexée. + /// + [HttpGet("knowledge/{instanceId}")] + [ProducesResponseType(typeof(GuideKnowledgeDTO), 200)] + [ProducesResponseType(typeof(string), 403)] + public async Task Knowledge(string instanceId) + { + if (!IsSuperAdmin() && GetCallerInstanceId() != instanceId) + return StatusCode(403, "Instance non autorisée"); + + var scope = _context.ContentEmbeddings.Where(e => e.InstanceId == instanceId); + + return Ok(new GuideKnowledgeDTO + { + indexedSections = await scope + .Where(e => e.ContentType == ContentSourceType.Section) + .Select(e => e.ContentId) + .Distinct() + .CountAsync(), + chunks = await scope.CountAsync(), + languages = await scope + .Select(e => e.Language) + .Distinct() + .OrderBy(l => l) + .ToListAsync(), + lastIndexedAt = await scope + .MaxAsync(e => (DateTime?)e.UpdatedAt) + }); + } + + /// + /// Ce que les visiteurs ont demandé au guide sur les 30 derniers jours. + /// + /// + /// Remplit exactement la structure attendue par l'onglet « Ce que demandent vos visiteurs » + /// de manager-app (GuideIaInsights) — c'est l'affichage qui a fixé le contrat. + /// + /// topics reste vide tant que le job de regroupement en thèmes n'a pas tourné : + /// ThemeId est nul à l'écriture, rempli a posteriori. L'écran dégrade proprement. + /// + /// Les questions sans réponse sont regroupées sur leur texte exact. Un regroupement + /// sémantique dirait mieux la même chose, mais il coûte un embedding par question et + /// c'est précisément le travail du job de thèmes — pas d'un endpoint de lecture. + /// + [HttpGet("insights/{instanceId}")] + [ProducesResponseType(typeof(GuideInsightsDTO), 200)] + [ProducesResponseType(typeof(string), 403)] + public async Task Insights(string instanceId, [FromQuery] int days = 30) + { + if (!IsSuperAdmin() && GetCallerInstanceId() != instanceId) + return StatusCode(403, "Instance non autorisée"); + + var since = DateTime.UtcNow.AddDays(-Math.Abs(days)); + var scope = _context.VisitorQuestions + .Where(q => q.InstanceId == instanceId && q.CreatedAt >= since); + + var citedIds = await scope + .SelectMany(q => q.CitedContentIds) + .ToListAsync(); + + // Les titres se résolvent en une seule requête, puis en mémoire : la liste des + // contenus cités est courte par nature, elle est déjà tronquée à 6. + var topCited = citedIds + .GroupBy(id => id) + .OrderByDescending(g => g.Count()) + .Take(6) + .ToList(); + var titles = await _context.Sections + .Where(s => topCited.Select(g => g.Key).Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s.Title); + + return Ok(new GuideInsightsDTO + { + 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(), + languages = await scope.Select(q => q.Language).Distinct().CountAsync(), + + unansweredQuestions = await scope + .Where(q => !q.HasAnswer) + .GroupBy(q => q.Question) + .Select(g => new CountedLabelDTO { label = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count) + .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() }) + .OrderByDescending(x => x.count) + .Take(6) + .ToListAsync(), + + questionLanguages = await scope + .GroupBy(q => q.Language) + .Select(g => new CountedLabelDTO { label = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count) + .ToListAsync(), + + citedContents = topCited + .Select(g => new CountedLabelDTO + { + label = titles.TryGetValue(g.Key, out var t) && t != null + ? t.FirstOrDefault()?.value ?? g.Key + : g.Key, + count = g.Count() + }) + .ToList() + }); + } + /// /// Traduit un texte HTML vers plusieurs langues via IA /// @@ -147,6 +367,7 @@ namespace ManagerService.Controllers var result = await _assistantService.ChatAsync(request); RecordUsage(instance, result.TokensUsed); + RecordVisitorQuestion(request, result); return Ok(result); } diff --git a/ManagerService/Controllers/ConfigurationController.cs b/ManagerService/Controllers/ConfigurationController.cs index 5a6ed37..164420e 100644 --- a/ManagerService/Controllers/ConfigurationController.cs +++ b/ManagerService/Controllers/ConfigurationController.cs @@ -657,7 +657,7 @@ namespace ManagerService.Controllers foreach (var section in exportConfiguration.sections.Where(s => !sectionsAlreadyInDB.Contains(s.id))) { - Section newSection = new Section(); + Section newSection = SectionFactory.CreateEmpty(section.type); newSection.Id = section.id; newSection.InstanceId = section.instanceId; newSection.Label = section.label; diff --git a/ManagerService/Controllers/InstanceController.cs b/ManagerService/Controllers/InstanceController.cs index 6ab0d20..107cc21 100644 --- a/ManagerService/Controllers/InstanceController.cs +++ b/ManagerService/Controllers/InstanceController.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Hangfire; using Manager.Services; using ManagerService.Data; using ManagerService.DTOs; @@ -27,9 +28,12 @@ namespace ManagerService.Controllers private readonly ILogger _logger; private readonly ProfileLogic _profileLogic; private readonly ApiKeyDatabaseService _apiKeyService; + // Injecté plutôt qu'appelé via la façade statique BackgroundJob : celle-ci lève + // sans JobStorage.Current, donc dans tout test qui touche cet endpoint. + private readonly IBackgroundJobClient _jobs; IHexIdGeneratorService idService = new HexIdGeneratorService(); - public InstanceController(ILogger logger, InstanceDatabaseService instanceService, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext, ApiKeyDatabaseService apiKeyService) + public InstanceController(ILogger logger, InstanceDatabaseService instanceService, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext, ApiKeyDatabaseService apiKeyService, IBackgroundJobClient jobs) { _logger = logger; _instanceService = instanceService; @@ -37,6 +41,7 @@ namespace ManagerService.Controllers _profileLogic = profileLogic; _myInfoMateDbContext = myInfoMateDbContext; _apiKeyService = apiKeyService; + _jobs = jobs; } /// @@ -94,10 +99,32 @@ namespace ManagerService.Controllers } } + /// + /// Recopie sur l'instance les valeurs portées par son plan. Elles sont dupliquées + /// volontairement — un client peut recevoir un geste commercial sans changer de plan — + /// mais elles doivent repartir du plan à chaque changement, sinon l'instance garde + /// les quotas de l'ancien. + /// + private void ApplyPlanQuotas(Instance instance) + { + if (instance.SubscriptionPlanId == null) + return; + + var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId); + if (plan == null) + return; + + instance.StorageQuotaBytes = plan.StorageQuotaBytes; + instance.AiTokensPerMonth = plan.AiTokensPerMonth; + instance.HasStats = plan.HasStats; + instance.StatsHistoryDays = plan.StatsHistoryDays; + instance.HasAdvancedStats = plan.HasAdvancedStats; + } + /// /// Create an instance /// - /// New instance info + /// New instance info //[AllowAnonymous] [ProducesResponseType(typeof(InstanceDTO), 200)] [ProducesResponseType(typeof(string), 400)] @@ -117,18 +144,7 @@ namespace ManagerService.Controllers instance.Id = idService.GenerateHexId(); // Copier les valeurs du plan comme valeurs par défaut - if (instance.SubscriptionPlanId != null) - { - var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId); - if (plan != null) - { - instance.StorageQuotaBytes = plan.StorageQuotaBytes; - instance.AiTokensPerMonth = plan.AiTokensPerMonth; - instance.HasStats = plan.HasStats; - instance.StatsHistoryDays = plan.StatsHistoryDays; - instance.HasAdvancedStats = plan.HasAdvancedStats; - } - } + ApplyPlanQuotas(instance); /*List instances = _instanceService.GetAll(); Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == id);*/ @@ -195,14 +211,33 @@ namespace ManagerService.Controllers instance.IsWeb = updatedInstance.isWeb ?? instance.IsWeb; instance.IsVR = updatedInstance.isVR ?? instance.IsVR; instance.IsAssistant = updatedInstance.isAssistant ?? instance.IsAssistant; + var previousPlanId = instance.SubscriptionPlanId; + var previousAiTokens = instance.AiTokensPerMonth; + if (updatedInstance.subscriptionPlanId == "") instance.SubscriptionPlanId = null; else if (updatedInstance.subscriptionPlanId != null) instance.SubscriptionPlanId = updatedInstance.subscriptionPlanId; + // CreateInstance recopie les valeurs du plan, pas Update : changer un client de + // Starter à Premium ne lui donnait donc ni stockage ni jetons IA supplémentaires. + // L'endpoint /quota masquait la moitié du problème en retombant sur le plan à la + // lecture, mais AiController lit `instance.AiTokensPerMonth` — un client passé à + // un plan payant restait à 0, donc sans IA. + if (instance.SubscriptionPlanId != previousPlanId) + ApplyPlanQuotas(instance); + //OldInstance instanceModified = _instanceService.Update(updatedInstance.Id, instance); _myInfoMateDbContext.SaveChanges(); + // Le contenu déjà créé n'a jamais été indexé tant que l'instance n'avait pas + // droit à l'IA : sans ce rattrapage, le client paie un guide qui ne connaît rien. + if (previousAiTokens <= 0 && instance.AiTokensPerMonth > 0) + { + var backfilledInstanceId = instance.Id; + _jobs.Enqueue(s => s.BackfillInstanceAsync(backfilledInstanceId)); + } + var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList(); return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList())); diff --git a/ManagerService/Controllers/ResourceController.cs b/ManagerService/Controllers/ResourceController.cs index b6c0260..6549a0e 100644 --- a/ManagerService/Controllers/ResourceController.cs +++ b/ManagerService/Controllers/ResourceController.cs @@ -339,6 +339,16 @@ namespace ManagerService.Controllers resource.InstanceId = newResource.instanceId; resource.Id = idService.GenerateHexId(); + // Les types URL pointent hors du bucket : ni chemin de stockage, ni poids à compter dans le quota. + bool hasBlob = resource.Type != ResourceType.ImageUrl + && resource.Type != ResourceType.VideoUrl + && resource.Type != ResourceType.JSONUrl; + if (hasBlob) + { + resource.StoragePath = $"pictures/{resource.InstanceId}/{resource.Id}"; + resource.SizeBytes = newResource.sizeBytes; + } + _myInfoMateDbContext.Add(resource); _myInfoMateDbContext.SaveChanges(); //OldResource resourceCreated = _resourceService.Create(resource); diff --git a/ManagerService/Controllers/SectionController.cs b/ManagerService/Controllers/SectionController.cs index dc524cf..e4b5ecd 100644 --- a/ManagerService/Controllers/SectionController.cs +++ b/ManagerService/Controllers/SectionController.cs @@ -533,100 +533,14 @@ namespace ManagerService.Controllers if (configuration == null) throw new KeyNotFoundException("Configuration does not exist"); - // Todo add some verification ? - Section section = new Section(); - // Preparation List languages = _configuration.GetSection("SupportedLanguages").Get>(); - switch (newSection.type) + Section section = SectionFactory.CreateEmpty(newSection.type); + if (section is SectionArticle article) { - case SectionType.Agenda: - section = new SectionAgenda - { - AgendaResourceIds = new List(), - EventAgendas = new List() - }; - break; - case SectionType.Article: - section = new SectionArticle - { - ArticleContents = new List(), - ArticleContent = LanguageInit.Init("Content", languages), - ArticleAudioIds = LanguageInit.Init("Audio", languages, true) - }; - break; - case SectionType.Event: - section = new SectionEvent - { - Programme = new List(), - ParcoursIds = new List() - }; - break; - case SectionType.Map: - section = new SectionMap - { - MapMapType = MapTypeApp.hybrid, - MapTypeMapbox = MapTypeMapBox.standard, - MapMapProvider = MapProvider.Google, - MapZoom = 18, - MapPoints = new List(), - MapCategories = new List() - }; - break; - case SectionType.Menu: - section = new SectionMenu - { - MenuSections = new List
(), - }; - break; - case SectionType.PDF: - section = new SectionPdf - { - PDFOrderedTranslationAndResources = [] - }; - break; - case SectionType.Game: - section = new SectionGame - { - GameMessageDebut = [], - GameMessageFin = [] - }; - break; - case SectionType.Quiz: - section = new SectionQuiz - { - QuizQuestions = new List(), - // TODO levels ? - }; - break; - case SectionType.Slider: - section = new SectionSlider - { - SliderContents = new List() - }; - break; - case SectionType.Video: - section = new SectionVideo - { - VideoSource = "", - }; - break; - case SectionType.Weather: - section = new SectionWeather(); - break; - case SectionType.Web: - section = new SectionWeb - { - WebSource = "", - }; - break; - case SectionType.Parcours: - section = new SectionParcours - { - GuidedPaths = new List() - }; - break; + article.ArticleContent = LanguageInit.Init("Content", languages); + article.ArticleAudioIds = LanguageInit.Init("Audio", languages, true); } section.InstanceId = newSection.instanceId; @@ -816,12 +730,16 @@ namespace ManagerService.Controllers MqttClientService.PublishMessage($"config/{existingSection.ConfigurationId}", JsonConvert.SerializeObject(new PlayerMessageDTO() { configChanged = true })); - if (existingSection.Type == SectionType.Agenda) - { - var sa = _myInfoMateDbContext.Sections.OfType().FirstOrDefault(s => s.Id == existingSection.Id); - if (sa?.IsOnlineAgenda == true && sa.AgendaResourceIds?.Count > 0) - BackgroundJob.Enqueue(s => s.SyncSectionAsync(sa.Id)); - } + // Un agenda en ligne doit d'abord rapatrier ses événements : la ré-indexation + // suivra le SaveChanges d'AgendaSyncService, donc sur les dates fraîches. + // Pour tous les autres cas, SectionIndexingInterceptor a déjà pris le relais + // au SaveChanges ci-dessus — rien à enqueue ici. + var syncedAgenda = existingSection.Type == SectionType.Agenda + && _myInfoMateDbContext.Sections.OfType() + .Any(s => s.Id == existingSection.Id && s.IsOnlineAgenda && s.AgendaResourceIds.Count > 0); + + if (syncedAgenda) + BackgroundJob.Enqueue(s => s.SyncSectionAsync(existingSection.Id)); } return new OkObjectResult(SectionFactory.ToDTO(existingSection)); diff --git a/ManagerService/DTOs/AiChatDTO.cs b/ManagerService/DTOs/AiChatDTO.cs index 550db2e..2049760 100644 --- a/ManagerService/DTOs/AiChatDTO.cs +++ b/ManagerService/DTOs/AiChatDTO.cs @@ -58,6 +58,24 @@ namespace ManagerService.DTOs ///
public bool ExpectsReply { get; set; } = true; public long TokensUsed { get; set; } + + /// + /// Contenus sur lesquels la réponse s'appuie, relevés côté serveur à chaque appel de + /// SearchKnowledge. Ils ne passent pas par le modèle : la règle « ne mentionne jamais + /// les identifiants techniques » lui interdit précisément de les recopier. + /// Destinés au client, pas au visiteur — c'est ce qui rend une réponse vérifiable + /// quand un client conteste ce que dit son guide. + /// + public List? Sources { get; set; } + } + + public class AiSourceDTO + { + public string ContentId { get; set; } + public string ContentType { get; set; } + + /// Null hors document paginé. + public int? PageNumber { get; set; } } public class AiTranslateRequest diff --git a/ManagerService/DTOs/GuideInsightsDTO.cs b/ManagerService/DTOs/GuideInsightsDTO.cs new file mode 100644 index 0000000..5a5ac52 --- /dev/null +++ b/ManagerService/DTOs/GuideInsightsDTO.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace ManagerService.DTOs +{ + /// + /// Ce que les visiteurs ont demandé au guide sur une période. + /// Miroir exact de `GuideIaInsights` dans manager-app : c'est l'écran qui a fixé la forme, + /// pour que le job de regroupement produise ce qui s'affiche plutôt que l'inverse. + /// + public class GuideInsightsDTO + { + public int questions { get; set; } + public int unanswered { get; set; } + public int themes { get; set; } + public int languages { get; set; } + + /// Les trous de contenu : ce que les visiteurs cherchent sans le trouver. + public List unansweredQuestions { get; set; } = new(); + + /// Vide tant que le job de regroupement en thèmes n'a pas tourné. + public List topics { get; set; } = new(); + + public List questionLanguages { get; set; } = new(); + public List citedContents { get; set; } = new(); + } + + public class CountedLabelDTO + { + public string label { get; set; } + public int count { get; set; } + } +} diff --git a/ManagerService/DTOs/GuideKnowledgeDTO.cs b/ManagerService/DTOs/GuideKnowledgeDTO.cs new file mode 100644 index 0000000..c020dff --- /dev/null +++ b/ManagerService/DTOs/GuideKnowledgeDTO.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace ManagerService.DTOs +{ + /// + /// Mesure de l'index vectoriel d'une instance : ce que le guide IA sait réellement. + /// Tous les chiffres viennent de ContentEmbedding, pas des tables de contenu. + /// + public class GuideKnowledgeDTO + { + public int indexedSections { get; set; } + public int chunks { get; set; } + public List languages { get; set; } = new(); + + /// Null tant que rien n'a été indexé. + public DateTime? lastIndexedAt { get; set; } + } +} diff --git a/ManagerService/Data/Section.cs b/ManagerService/Data/Section.cs index c23e50a..4ec52dc 100644 --- a/ManagerService/Data/Section.cs +++ b/ManagerService/Data/Section.cs @@ -1,8 +1,10 @@ -using ManagerService.DTOs; +using Manager.DTOs; +using ManagerService.DTOs; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; using Microsoft.EntityFrameworkCore; @@ -12,7 +14,7 @@ namespace ManagerService.Data /// Section Information /// [Index(nameof(InstanceId))] - public class Section : IAuditableEntity + public abstract class Section : IAuditableEntity { [Key] [Required] @@ -31,7 +33,7 @@ namespace ManagerService.Data public int Order { get; set; } [Required] - public string ConfigurationId { get; set; } // Parent id + public string ConfigurationId { get; set; } // Parent id public string ImageId { get; set; } @@ -64,6 +66,30 @@ namespace ManagerService.Data public bool IsActive { get; set; } = true; + /// + /// Texte de la section dans une langue, tel qu'il sera indexé pour le guide IA. + /// Rend une chaîne vide quand la section ne porte rien d'exploitable dans cette langue. + /// + /// + /// Abstraite, et pas virtuelle avec un comportement par défaut : la collecte des + /// ressources vivait dans un switch centralisé, un nouveau sous-type n'obligeait + /// personne à le mettre à jour, et il est devenu faux en silence. Ici le compilateur + /// réclame l'implémentation. + /// + public abstract string GetEmbeddableText(string language); + + /// + /// Ids des ressources référencées par la section, pour l'export hors ligne. + /// null rend toutes les langues. + /// + public abstract IEnumerable GetReferencedResourceIds(string language = null); + + /// Titre et description, communs à tous les sous-types. + protected string BaseText(string language) => + SectionText.JoinText(SectionText.Translate(Title, language), SectionText.Translate(Description, language)); + + /// Vignette de la section, commune à tous les sous-types. + protected IEnumerable BaseResourceIds() => SectionText.ResourceId(ImageId); public SectionDTO ToDTO() { diff --git a/ManagerService/Data/SectionIndexingInterceptor.cs b/ManagerService/Data/SectionIndexingInterceptor.cs new file mode 100644 index 0000000..c2ec6b5 --- /dev/null +++ b/ManagerService/Data/SectionIndexingInterceptor.cs @@ -0,0 +1,200 @@ +using Hangfire; +using ManagerService.Data.SubSection; +using ManagerService.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using static ManagerService.Data.SubSection.SectionEvent; + +namespace ManagerService.Data +{ + /// + /// Déclenche la ré-indexation des sections touchées par un enregistrement. + /// + /// + /// Central plutôt qu'un Enqueue par contrôleur : le contenu d'une section se modifie + /// depuis six endroits (SectionController, les sous-contrôleurs Map/Quiz/Event/Parcours/Agenda, + /// et AgendaSyncService), et la plupart d'entre eux enregistrent une entité fille sans jamais + /// toucher la ligne Section. Un enqueue posé dans chaque contrôleur laisse passer tout ce qu'on + /// oublie d'y ajouter — c'est déjà comme ça que le switch de l'export hors ligne a pourri. + /// + public class SectionIndexingInterceptor : SaveChangesInterceptor + { + private readonly IBackgroundJobClient _jobs; + + private readonly HashSet _sectionIds = new(); + private readonly HashSet _deletedSectionIds = new(); + private readonly HashSet _guidedPathIds = new(); + private readonly HashSet _guidedStepIds = new(); + + public SectionIndexingInterceptor(IBackgroundJobClient jobs) + { + _jobs = jobs; + } + + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + Collect(eventData.Context); + return result; + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + Collect(eventData.Context); + return ValueTask.FromResult(result); + } + + public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) + { + Enqueue(eventData.Context); + return result; + } + + public override ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) + { + Enqueue(eventData.Context); + return ValueTask.FromResult(result); + } + + /// + /// Les entités supprimées ne sont plus interrogeables une fois l'enregistrement passé : + /// les clés étrangères sont donc relevées ici, avant. Les propriétés sont lues via l'entrée + /// et non l'objet, pour atteindre aussi les clés étrangères fantômes (ProgrammeBlock). + /// + private void Collect(DbContext context) + { + if (context == null) return; + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) + continue; + + switch (entry.Entity) + { + case Section section: + if (entry.State == EntityState.Deleted) + Add(_deletedSectionIds, section.Id); + else if (entry.State == EntityState.Added || TouchesIndexedContent(entry)) + Add(_sectionIds, section.Id); + break; + + case GeoPoint point: + Add(_sectionIds, point.SectionMapId ?? point.SectionEventId); + break; + + case EventAgenda eventAgenda: + Add(_sectionIds, eventAgenda.SectionAgendaId ?? eventAgenda.SectionEventId); + break; + + case MapAnnotation annotation: + // Null pour une annotation de bloc : c'est alors le ProgrammeBlock qui porte + // le rattachement à l'événement, et il est enregistré dans la même passe. + Add(_sectionIds, annotation.SectionEventId); + break; + + case ProgrammeBlock: + Add(_sectionIds, entry.Property("SectionEventId").CurrentValue as string); + break; + + case GuidedPath path: + Add(_sectionIds, path.SectionParcoursId ?? path.SectionEventId); + break; + + case GuidedStep step: + Add(_guidedPathIds, step.GuidedPathId); + break; + + case QuizQuestion question: + Add(_sectionIds, question.SectionQuizId); + Add(_guidedStepIds, question.GuidedStepId); + break; + } + } + } + + private void Enqueue(DbContext context) + { + if (context == null) return; + + var sectionIds = Drain(_sectionIds); + var deletedSectionIds = Drain(_deletedSectionIds); + var guidedPathIds = Drain(_guidedPathIds); + var guidedStepIds = Drain(_guidedStepIds); + + // La purge ne coûte rien et doit passer quel que soit le plan : sans elle, une section + // supprimée continue d'alimenter les réponses du guide. + foreach (var sectionId in deletedSectionIds) + _jobs.Enqueue(s => s.DeleteAsync(sectionId, ContentSourceType.Section, default)); + + if (guidedStepIds.Count > 0) + { + foreach (var pathId in context.Set() + .Where(s => guidedStepIds.Contains(s.Id)) + .Select(s => s.GuidedPathId) + .ToList()) + Add(guidedPathIds, pathId); + } + + if (guidedPathIds.Count > 0) + { + foreach (var ownerId in context.Set() + .Where(p => guidedPathIds.Contains(p.Id)) + .Select(p => p.SectionParcoursId ?? p.SectionEventId) + .ToList()) + Add(sectionIds, ownerId); + } + + sectionIds.ExceptWith(deletedSectionIds); + if (sectionIds.Count == 0) return; + + // Un plan sans IA n'a pas à payer d'embedding : plan-starter est à 0 jeton, et + // l'indexation étant silencieuse, rien ne le signalerait avant la facture Google. + var indexable = context.Set
() + .Where(s => sectionIds.Contains(s.Id)) + .Join(context.Set().Where(i => i.AiTokensPerMonth > 0), + s => s.InstanceId, i => i.Id, (s, _) => s.Id) + .ToList(); + + foreach (var sectionId in indexable) + _jobs.Enqueue(s => s.IngestSectionAsync(sectionId)); + } + + private static HashSet Drain(HashSet source) + { + var copy = new HashSet(source); + source.Clear(); + return copy; + } + + /// + /// Réordonner des sections ne change pas un mot du texte indexé. Sans ce filtre, un + /// glisser-déposer dans le manager fait payer un embedding par section déplacée — + /// et plusieurs contrôleurs enregistrent dans une boucle, un SaveChanges par élément. + /// + private static readonly HashSet NonIndexedSectionProperties = new() + { + nameof(Section.Order), nameof(Section.DateUpdate) + }; + + /// + /// Repli volontairement prudent : toute autre propriété modifiée déclenche la + /// ré-indexation. Un champ ajouté plus tard sera donc réindexé pour rien plutôt + /// qu'oublié en silence — le sens de l'erreur qui se voit. + /// + private static bool TouchesIndexedContent(EntityEntry entry) => + entry.Properties.Any(p => p.IsModified && !NonIndexedSectionProperties.Contains(p.Metadata.Name)); + + private static void Add(HashSet target, string id) + { + if (!string.IsNullOrEmpty(id)) + target.Add(id); + } + } +} diff --git a/ManagerService/Data/SectionText.cs b/ManagerService/Data/SectionText.cs new file mode 100644 index 0000000..06855dd --- /dev/null +++ b/ManagerService/Data/SectionText.cs @@ -0,0 +1,51 @@ +using Manager.DTOs; +using ManagerService.DTOs; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ManagerService.Data +{ + /// + /// Briques communes à et + /// . Sorties de parce que + /// les structures parcourues (GeoPoint, GuidedStep, EventAgenda…) n'en héritent pas. + /// + public static class SectionText + { + public static string Translate(IEnumerable translations, string language) => + translations?.FirstOrDefault(t => string.Equals(t.language, language, StringComparison.OrdinalIgnoreCase))?.value; + + public static string Translate(IEnumerable translations, string language) => + translations?.FirstOrDefault(t => string.Equals(t.language, language, StringComparison.OrdinalIgnoreCase))?.value; + + public static string TranslateContents(IEnumerable contents, string language) => + JoinText((contents ?? Enumerable.Empty()) + .OrderBy(c => c.order ?? 0) + .SelectMany(c => new[] { Translate(c.title, language), Translate(c.description, language) }) + .ToArray()); + + public static string JoinText(params string[] parts) => + string.Join("\n", parts.Where(p => !string.IsNullOrWhiteSpace(p))); + + public static IEnumerable ResourceId(string id) => + string.IsNullOrWhiteSpace(id) ? Enumerable.Empty() : new[] { id }; + + /// + /// Champs où la valeur traduite est un id de ressource (audios, PDF, JSON d'agenda), + /// et non du texte à afficher. + /// + public static IEnumerable ResourceIdsFromValues(IEnumerable translations, string language) => + (translations ?? Enumerable.Empty()) + .Where(t => language == null || string.Equals(t.language, language, StringComparison.OrdinalIgnoreCase)) + .SelectMany(t => ResourceId(t.value)); + + public static IEnumerable ResourceIds(IEnumerable translations, string language) => + (translations ?? Enumerable.Empty()) + .Where(t => language == null || string.Equals(t.language, language, StringComparison.OrdinalIgnoreCase)) + .SelectMany(t => ResourceId(t.resourceId)); + + public static IEnumerable ResourceIds(IEnumerable contents) => + (contents ?? Enumerable.Empty()).SelectMany(c => ResourceId(c.resourceId)); + } +} diff --git a/ManagerService/Data/SubSection/EventAgenda.cs b/ManagerService/Data/SubSection/EventAgenda.cs index 873b0e7..3d46f23 100644 --- a/ManagerService/Data/SubSection/EventAgenda.cs +++ b/ManagerService/Data/SubSection/EventAgenda.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; namespace ManagerService.Data.SubSection { @@ -68,6 +69,18 @@ namespace ManagerService.Data.SubSection [ForeignKey("SectionEventId")] public SectionEvent? SectionEvent { get; set; } // Genre lancer l'event (vue vraiment detail d'un event) + public string GetEmbeddableText(string language) => + SectionText.JoinText( + SectionText.Translate(Label, language), + SectionText.Translate(Description, language), + Type, + Address?.City, + DateFrom.HasValue ? $"{DateFrom:yyyy-MM-dd} → {DateTo:yyyy-MM-dd}" : null); + + // SyncedImageUrl pointe vers une image externe, sans ligne Resource : rien à télécharger. + public IEnumerable GetReferencedResourceIds() => + SectionText.ResourceId(ResourceId).Concat(SectionText.ResourceId(VideoResourceId)); + public EventAgendaDTO ToDTO() { return new EventAgendaDTO() diff --git a/ManagerService/Data/SubSection/GuidedPath.cs b/ManagerService/Data/SubSection/GuidedPath.cs index 6fa7909..fc8dedc 100644 --- a/ManagerService/Data/SubSection/GuidedPath.cs +++ b/ManagerService/Data/SubSection/GuidedPath.cs @@ -63,6 +63,26 @@ namespace ManagerService.Data.SubSection public List Steps { get; set; } = new(); + public string GetEmbeddableText(string language) => + SectionText.JoinText(new[] + { + SectionText.Translate(Title, language), + SectionText.Translate(Description, language), + SectionText.Translate(GameMessageDebut, language), + SectionText.Translate(GameMessageFin, language) + } + .Concat((Steps ?? new List()) + .OrderBy(s => s.Order) + .Select(s => s.GetEmbeddableText(language))) + .ToArray()); + + public IEnumerable GetReferencedResourceIds(string language = null) => + SectionText.ResourceId(ImageResourceId) + .Concat(SectionText.ResourceIds(GameMessageDebut, language)) + .Concat(SectionText.ResourceIds(GameMessageFin, language)) + .Concat((Steps ?? new List()) + .SelectMany(s => s.GetReferencedResourceIds(language))); + public GuidedPathDTO ToDTO() { return new GuidedPathDTO diff --git a/ManagerService/Data/SubSection/GuidedStep.cs b/ManagerService/Data/SubSection/GuidedStep.cs index 5db41b6..d3b0a6c 100644 --- a/ManagerService/Data/SubSection/GuidedStep.cs +++ b/ManagerService/Data/SubSection/GuidedStep.cs @@ -58,6 +58,27 @@ namespace ManagerService.Data.SubSection [Column(TypeName = "jsonb")] public List TimerExpiredMessage { get; set; } + // Même règle que SectionQuiz : les réponses des énigmes ne sont pas indexées. + public string GetEmbeddableText(string language) => + SectionText.JoinText(new[] + { + SectionText.Translate(Title, language), + SectionText.Translate(Description, language), + SectionText.TranslateContents(Contents, language), + SectionText.Translate(TimerExpiredMessage, language) + } + .Concat((QuizQuestions ?? new List()) + .OrderBy(q => q.Order) + .Select(q => SectionText.Translate(q.Label, language))) + .ToArray()); + + // ImageUrl est une URL absolue, pas un id : rien à rapprocher d'une ligne Resource. + public IEnumerable GetReferencedResourceIds(string language = null) => + SectionText.ResourceIdsFromValues(AudioIds, language) + .Concat(SectionText.ResourceIds(Contents)) + .Concat((QuizQuestions ?? new List()) + .SelectMany(q => q.GetReferencedResourceIds(language))); + public GuidedStepDTO ToDTO() { return new GuidedStepDTO diff --git a/ManagerService/Data/SubSection/QuizQuestion.cs b/ManagerService/Data/SubSection/QuizQuestion.cs index 1ea7c2f..7c978c3 100644 --- a/ManagerService/Data/SubSection/QuizQuestion.cs +++ b/ManagerService/Data/SubSection/QuizQuestion.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; using System.Text.Json.Serialization; namespace ManagerService.Data.SubSection @@ -55,6 +56,12 @@ namespace ManagerService.Data.SubSection public bool? IsSlidingPuzzle { get; set; } = false; + public IEnumerable GetReferencedResourceIds(string language = null) => + SectionText.ResourceId(ResourceId) + .Concat(SectionText.ResourceId(PuzzleImageId)) + .Concat((Responses ?? new List()) + .SelectMany(r => SectionText.ResourceIds(r.label, language))); + // TODO /*public TranslationDTO ToDTO() { diff --git a/ManagerService/Data/SubSection/SectionAgenda.cs b/ManagerService/Data/SubSection/SectionAgenda.cs index 45ea712..7f54d38 100644 --- a/ManagerService/Data/SubSection/SectionAgenda.cs +++ b/ManagerService/Data/SubSection/SectionAgenda.cs @@ -6,6 +6,8 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -25,6 +27,18 @@ namespace ManagerService.Data.SubSection [Required] public List EventAgendas { get; set; } + public override string GetEmbeddableText(string language) => + JoinText(new[] { BaseText(language) } + .Concat((EventAgendas ?? new List()) + .Select(e => e.GetEmbeddableText(language))) + .ToArray()); + + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds() + .Concat(ResourceIdsFromValues(AgendaResourceIds, language)) + .Concat((EventAgendas ?? new List()) + .SelectMany(e => e.GetReferencedResourceIds())); + public AgendaDTO ToDTO() { return new AgendaDTO() diff --git a/ManagerService/Data/SubSection/SectionArticle.cs b/ManagerService/Data/SubSection/SectionArticle.cs index 8b1cb90..16e4c9a 100644 --- a/ManagerService/Data/SubSection/SectionArticle.cs +++ b/ManagerService/Data/SubSection/SectionArticle.cs @@ -3,8 +3,11 @@ using ManagerService.DTOs; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -28,6 +31,16 @@ namespace ManagerService.Data.SubSection [Column(TypeName = "jsonb")] public List ArticleContents { get; set; } // List of picture etc + public override string GetEmbeddableText(string language) => + JoinText(BaseText(language), + Translate(ArticleContent, language), + TranslateContents(ArticleContents, language)); + + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds() + .Concat(ResourceIdsFromValues(ArticleAudioIds, language)) + .Concat(ResourceIds(ArticleContents)); + public ArticleDTO ToDTO() { return new ArticleDTO() diff --git a/ManagerService/Data/SubSection/SectionEvent.cs b/ManagerService/Data/SubSection/SectionEvent.cs index 4c8b4b7..5b2afa4 100644 --- a/ManagerService/Data/SubSection/SectionEvent.cs +++ b/ManagerService/Data/SubSection/SectionEvent.cs @@ -8,6 +8,8 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -25,6 +27,31 @@ namespace ManagerService.Data.SubSection [Column(TypeName = "jsonb")] public List ParcoursIds { get; set; } = new(); // Liens vers GeoPoints spécifiques + public override string GetEmbeddableText(string language) => + JoinText(new[] + { + BaseText(language), + StartDate.HasValue ? $"{StartDate:yyyy-MM-dd} → {EndDate:yyyy-MM-dd}" : null + } + .Concat((Programme ?? new List()) + .OrderBy(b => b.StartTime) + .SelectMany(b => new[] + { + Translate(b.Title, language), + Translate(b.Description, language) + })) + .Concat((GlobalMapAnnotations ?? new List()) + .Select(a => Translate(a.Label, language))) + .ToArray()); + + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds() + .Concat((GlobalMapAnnotations ?? new List()) + .SelectMany(a => ResourceId(a.IconResourceId))) + .Concat((Programme ?? new List()) + .SelectMany(b => b.MapAnnotations ?? new List()) + .SelectMany(a => ResourceId(a.IconResourceId))); + public class ProgrammeBlock { [Key] diff --git a/ManagerService/Data/SubSection/SectionGame.cs b/ManagerService/Data/SubSection/SectionGame.cs index 3abb4dc..fe5bbbd 100644 --- a/ManagerService/Data/SubSection/SectionGame.cs +++ b/ManagerService/Data/SubSection/SectionGame.cs @@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -33,6 +35,17 @@ namespace ManagerService.Data.SubSection public GameTypes GameType { get; set; } = GameTypes.Puzzle; + public override string GetEmbeddableText(string language) => + JoinText(BaseText(language), + Translate(GameMessageDebut, language), + Translate(GameMessageFin, language)); + + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds() + .Concat(ResourceId(GamePuzzleImageId)) + .Concat(ResourceIds(GameMessageDebut, language)) + .Concat(ResourceIds(GameMessageFin, language)); + public GameDTO ToDTO() { return new GameDTO() diff --git a/ManagerService/Data/SubSection/SectionMap.cs b/ManagerService/Data/SubSection/SectionMap.cs index 00d2c6a..1ac6126 100644 --- a/ManagerService/Data/SubSection/SectionMap.cs +++ b/ManagerService/Data/SubSection/SectionMap.cs @@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -29,6 +31,23 @@ namespace ManagerService.Data.SubSection public string MapCenterLongitude { get; set; } // Center on + public override string GetEmbeddableText(string language) => + JoinText(new[] { BaseText(language) } + .Concat((MapCategories ?? new List()) + .OrderBy(c => c.order ?? 0) + .Select(c => Translate(c.label, language))) + .Concat((MapPoints ?? new List()) + .Select(p => p.GetEmbeddableText(language))) + .ToArray()); + + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds() + .Concat(ResourceId(MapResourceId)) + .Concat((MapCategories ?? new List()) + .SelectMany(c => ResourceId(c.resourceDTO?.id))) + .Concat((MapPoints ?? new List()) + .SelectMany(p => p.GetReferencedResourceIds())); + public MapDTO ToDTO() { return new MapDTO() @@ -120,6 +139,19 @@ namespace ManagerService.Data.SubSection [ForeignKey(nameof(SectionEventId))] public SectionEvent? SectionEvent { get; set; } + public string GetEmbeddableText(string language) => + JoinText(Translate(Title, language), + Translate(Description, language), + TranslateContents(Contents, language), + Translate(Schedules, language), + Translate(Prices, language), + Translate(Phone, language), + Translate(Email, language), + Translate(Site, language)); + + public IEnumerable GetReferencedResourceIds() => + ResourceId(ImageResourceId).Concat(ResourceIds(Contents)); + public GeoPointDTO ToDTO() { return new GeoPointDTO diff --git a/ManagerService/Data/SubSection/SectionMenu.cs b/ManagerService/Data/SubSection/SectionMenu.cs index 685fa71..230bf9e 100644 --- a/ManagerService/Data/SubSection/SectionMenu.cs +++ b/ManagerService/Data/SubSection/SectionMenu.cs @@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -17,6 +19,12 @@ namespace ManagerService.Data.SubSection [Required] public List
MenuSections { get; set; } // All json files for all languages + // MenuSections sont des sections à part entière, indexées et exportées pour elles-mêmes : + // les reprendre ici dupliquerait leur contenu. + public override string GetEmbeddableText(string language) => BaseText(language); + + public override IEnumerable GetReferencedResourceIds(string language = null) => BaseResourceIds(); + public MenuDTO ToDTO() { return new MenuDTO() diff --git a/ManagerService/Data/SubSection/SectionParcours.cs b/ManagerService/Data/SubSection/SectionParcours.cs index 8a6fb1b..68ec446 100644 --- a/ManagerService/Data/SubSection/SectionParcours.cs +++ b/ManagerService/Data/SubSection/SectionParcours.cs @@ -1,10 +1,12 @@ -using Manager.DTOs; +using Manager.DTOs; using ManagerService.DTOs; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { public class SectionParcours : Section @@ -17,6 +19,18 @@ namespace ManagerService.Data.SubSection public List GuidedPaths { get; set; } = new(); + public override string GetEmbeddableText(string language) => + JoinText(new[] { BaseText(language) } + .Concat((GuidedPaths ?? new List()) + .OrderBy(p => p.Order) + .Select(p => p.GetEmbeddableText(language))) + .ToArray()); + + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds() + .Concat((GuidedPaths ?? new List()) + .SelectMany(p => p.GetReferencedResourceIds(language))); + public ParcoursDTO ToDTO() { return new ParcoursDTO diff --git a/ManagerService/Data/SubSection/SectionPdf.cs b/ManagerService/Data/SubSection/SectionPdf.cs index 9edcf72..bb56765 100644 --- a/ManagerService/Data/SubSection/SectionPdf.cs +++ b/ManagerService/Data/SubSection/SectionPdf.cs @@ -6,6 +6,8 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -17,6 +19,20 @@ namespace ManagerService.Data.SubSection [Column(TypeName = "jsonb")] public List PDFOrderedTranslationAndResources { get; set; } // All json files for all languages + // Le texte des PDF eux-mêmes relève de l'ingestion documentaire (V2) : ici on n'indexe + // que les libellés saisis dans le CMS. + public override string GetEmbeddableText(string language) => + JoinText(new[] { BaseText(language) } + .Concat((PDFOrderedTranslationAndResources ?? new List()) + .OrderBy(p => p.order ?? 0) + .Select(p => Translate(p.translationAndResourceDTOs, language))) + .ToArray()); + + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds() + .Concat((PDFOrderedTranslationAndResources ?? new List()) + .SelectMany(p => ResourceIds(p.translationAndResourceDTOs, language))); + public PdfDTO ToDTO() { return new PdfDTO() diff --git a/ManagerService/Data/SubSection/SectionQuiz.cs b/ManagerService/Data/SubSection/SectionQuiz.cs index 38e544f..ec85b65 100644 --- a/ManagerService/Data/SubSection/SectionQuiz.cs +++ b/ManagerService/Data/SubSection/SectionQuiz.cs @@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -34,6 +36,25 @@ namespace ManagerService.Data.SubSection public List QuizGreatLevel { get; set; } + // Les réponses ne sont pas indexées : le guide les réciterait au premier visiteur qui + // demande. Les libellés de questions, eux, portent le sujet et rendent la section + // trouvable. Les messages de niveau sont des félicitations, sans valeur informative. + public override string GetEmbeddableText(string language) => + JoinText(new[] { BaseText(language) } + .Concat((QuizQuestions ?? new List()) + .OrderBy(q => q.Order) + .Select(q => Translate(q.Label, language))) + .ToArray()); + + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds() + .Concat(ResourceIds(QuizBadLevel, language)) + .Concat(ResourceIds(QuizMediumLevel, language)) + .Concat(ResourceIds(QuizGoodLevel, language)) + .Concat(ResourceIds(QuizGreatLevel, language)) + .Concat((QuizQuestions ?? new List()) + .SelectMany(q => q.GetReferencedResourceIds(language))); + public QuizDTO ToDTO() { return new QuizDTO() diff --git a/ManagerService/Data/SubSection/SectionSlider.cs b/ManagerService/Data/SubSection/SectionSlider.cs index 53890b9..2160a73 100644 --- a/ManagerService/Data/SubSection/SectionSlider.cs +++ b/ManagerService/Data/SubSection/SectionSlider.cs @@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -18,6 +20,12 @@ namespace ManagerService.Data.SubSection [Column(TypeName = "jsonb")] public List SliderContents { get; set; } // TODO check + public override string GetEmbeddableText(string language) => + JoinText(BaseText(language), TranslateContents(SliderContents, language)); + + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds().Concat(ResourceIds(SliderContents)); + public SliderDTO ToDTO() { return new SliderDTO() diff --git a/ManagerService/Data/SubSection/SectionVideo.cs b/ManagerService/Data/SubSection/SectionVideo.cs index 884087c..92b18e8 100644 --- a/ManagerService/Data/SubSection/SectionVideo.cs +++ b/ManagerService/Data/SubSection/SectionVideo.cs @@ -1,7 +1,11 @@ using Manager.DTOs; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -12,6 +16,16 @@ namespace ManagerService.Data.SubSection [Required] public string VideoSource { get; set; } // url to resource id (local) or on internet + public override string GetEmbeddableText(string language) => BaseText(language); + + // VideoSource porte soit un id de ressource uploadée, soit une URL YouTube/Vimeo — + // seule la première est téléchargeable hors ligne. + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds() + .Concat(VideoSource != null && VideoSource.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? Enumerable.Empty() + : ResourceId(VideoSource)); + public VideoDTO ToDTO() { return new VideoDTO() diff --git a/ManagerService/Data/SubSection/SectionWeather.cs b/ManagerService/Data/SubSection/SectionWeather.cs index 2aebde5..bf696ef 100644 --- a/ManagerService/Data/SubSection/SectionWeather.cs +++ b/ManagerService/Data/SubSection/SectionWeather.cs @@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -20,6 +22,13 @@ namespace ManagerService.Data.SubSection public string WeatherResult { get; set; } // Weather result + // WeatherResult est un instantané réécrit à chaque rafraîchissement : l'indexer + // ferait re-calculer les embeddings en boucle pour une météo déjà périmée. + public override string GetEmbeddableText(string language) => + JoinText(BaseText(language), WeatherCity); + + public override IEnumerable GetReferencedResourceIds(string language = null) => BaseResourceIds(); + public WeatherDTO ToDTO() { return new WeatherDTO() diff --git a/ManagerService/Data/SubSection/SectionWeb.cs b/ManagerService/Data/SubSection/SectionWeb.cs index f9eb4da..f389105 100644 --- a/ManagerService/Data/SubSection/SectionWeb.cs +++ b/ManagerService/Data/SubSection/SectionWeb.cs @@ -1,8 +1,11 @@ using Manager.DTOs; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using static ManagerService.Data.SectionText; + namespace ManagerService.Data.SubSection { /// @@ -13,6 +16,11 @@ namespace ManagerService.Data.SubSection [Required] public string WebSource { get; set; } // url to resource id (local) or on internet + // WebSource pointe vers une page externe : son contenu n'est ni stocké ni téléchargeable. + public override string GetEmbeddableText(string language) => BaseText(language); + + public override IEnumerable GetReferencedResourceIds(string language = null) => BaseResourceIds(); + public WebDTO ToDTO() { return new WebDTO() diff --git a/ManagerService/Services/AgendaSyncService.cs b/ManagerService/Services/AgendaSyncService.cs index f1d98fc..9491c25 100644 --- a/ManagerService/Services/AgendaSyncService.cs +++ b/ManagerService/Services/AgendaSyncService.cs @@ -122,6 +122,9 @@ namespace ManagerService.Services } } + // Les EventAgenda modifiés ci-dessus déclenchent la ré-indexation via + // SectionIndexingInterceptor : elle porte donc sur les dates fraîches, pas + // sur celles d'avant la synchro. db.SaveChanges(); _logger.LogInformation("Synced agenda section {Id}", sectionAgendaId); } diff --git a/ManagerService/Services/AssistantService.cs b/ManagerService/Services/AssistantService.cs index 72d1c42..904aedc 100644 --- a/ManagerService/Services/AssistantService.cs +++ b/ManagerService/Services/AssistantService.cs @@ -19,14 +19,27 @@ namespace ManagerService.Services private readonly IChatClient _chatClient; private readonly MyInfoMateDbContext _context; private readonly IHttpClientFactory _httpClientFactory; + private readonly IVectorStoreService _vectorStore; private const int MaxHistoryMessages = 10; - public AssistantService(IChatClient chatClient, MyInfoMateDbContext context, IHttpClientFactory httpClientFactory) + /// + /// Règle à reprendre dans les quatre prompts. Le second membre n'est pas décoratif : + /// sans lui, Gemini comble les trous avec ses connaissances générales, et une réponse + /// inventée sur un horaire ou une date de construction est pire qu'un « je ne sais pas ». + /// + private const string SearchKnowledgeRule = + "Toute question de fond sur le contenu (œuvres, histoire, explications, thématiques) → appelle " + + "\"SearchKnowledge\" AVANT de répondre. Si l'outil ne rend rien, dis que l'information n'est pas " + + "disponible ici — n'invente JAMAIS et ne réponds pas depuis tes connaissances générales."; + + public AssistantService(IChatClient chatClient, MyInfoMateDbContext context, + IHttpClientFactory httpClientFactory, IVectorStoreService vectorStore) { _chatClient = chatClient; _context = context; _httpClientFactory = httpClientFactory; + _vectorStore = vectorStore; } public async Task TranslateAsync(AiTranslateRequest request) @@ -194,6 +207,26 @@ namespace ManagerService.Services return $"réponds exactement ceci, sans rien y ajouter : « {picked} »"; } + /// + /// Dédoublonne sur (contenu, page) : plusieurs morceaux d'une même section remontent + /// souvent ensemble, et le client n'a pas besoin de voir trois fois la même source. + /// + private static void RecordSources(List sources, List hits) + { + foreach (var hit in hits) + { + if (sources.Any(s => s.ContentId == hit.ContentId && s.PageNumber == hit.PageNumber)) + continue; + + sources.Add(new AiSourceDTO + { + ContentId = hit.ContentId, + ContentType = hit.ContentType.ToString(), + PageNumber = hit.PageNumber + }); + } + } + public async Task ChatAsync(AiChatRequest request) { var messages = new List(); @@ -247,6 +280,7 @@ namespace ManagerService.Services 12. Tu réponds UNIQUEMENT aux questions liées à cette visite et au contenu disponible dans les sections ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle…), {fallbackInstruction} — et ajoute [FIN]. 13. Politesses et fin de conversation ("merci", "au revoir", "c'est tout", "ok merci") → réponds en une phrase courte et souhaite une bonne visite, puis ajoute [FIN]. 14. Signal [FIN] : ajoute le token [FIN] à la toute fin quand ta réponse est une information pure sans question posée au visiteur. Ne l'ajoute PAS quand tu poses une question (article, proposition de détails). + 15. {SearchKnowledgeRule} """ : $""" Tu es le guide de visite de l'application "{config?.Label ?? "cette application"}". {guideIdentity} @@ -265,6 +299,7 @@ namespace ManagerService.Services 5. Si l'utilisateur veut voir les activités ou lieux → utilise "GetMapPoints". 6. Pour les détails d'un item spécifique → utilise "GetItemDetails". 7. Tu réponds UNIQUEMENT aux questions liées à cette visite et au contenu disponible dans les sections ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle…), {fallbackInstruction}. + 8. {SearchKnowledgeRule} - Ne mentionne JAMAIS les identifiants techniques (id, guid) dans tes réponses finales. - NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats. - NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide. @@ -279,6 +314,7 @@ namespace ManagerService.Services NavigationActionDTO? navigation = null; List? cards = null; + var sources = new List(); var tools = new List { @@ -499,6 +535,23 @@ namespace ManagerService.Services "GetItemDetails", "Récupère les détails complets (prix, horaires, contact, site) d'un événement (type='event') ou d'un point d'intérêt (type='poi') via son ID." ), + AIFunctionFactory.Create( + async (string question) => + { + var hits = await _vectorStore.SearchAsync( + request.InstanceId, request.ConfigurationId, request.Language, question); + + // Dire qu'on n'a rien plutôt que rendre une chaîne vide : sans réponse + // explicite, le modèle comble le silence avec ses connaissances générales. + if (hits.Count == 0) + return "Aucun contenu du lieu ne traite de ce sujet."; + + RecordSources(sources, hits); + return string.Join("\n---\n", hits.Select(h => h.Text)); + }, + "SearchKnowledge", + "Cherche dans le contenu rédigé du lieu (descriptions, articles, étapes de parcours) ce qui traite d'un sujet. À utiliser pour toute question de fond sur les œuvres, l'histoire, les thématiques." + ), }; // Navigation et cards : uniquement en mode UI (pas Voice) @@ -545,7 +598,7 @@ namespace ManagerService.Services reply = response.Text ?? ""; expectsReply = true; } - return new AiChatResponse { Reply = reply, Cards = cards, Navigation = navigation, ExpectsReply = expectsReply, TokensUsed = response.Usage?.TotalTokenCount ?? 0 }; + return new AiChatResponse { Reply = reply, Cards = cards, Navigation = navigation, ExpectsReply = expectsReply, TokensUsed = response.Usage?.TotalTokenCount ?? 0, Sources = sources.Count > 0 ? sources : null }; } catch (System.ClientModel.ClientResultException ex) { @@ -603,6 +656,7 @@ namespace ManagerService.Services 6. Tu réponds UNIQUEMENT aux questions liées à cette visite. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle…), {fallbackInstruction} — et ajoute [FIN]. 7. Politesses ("merci", "au revoir") → réponds en une phrase courte et souhaite une bonne visite, puis ajoute [FIN]. 8. Signal [FIN] : ajoute-le quand ta réponse est une information pure sans question au visiteur. + 9. {SearchKnowledgeRule} """ : $""" Tu es le guide principal. Ton rôle est d'orienter le visiteur vers la bonne expérience de visite. {guideIdentity} @@ -621,6 +675,7 @@ namespace ManagerService.Services 5. Pour orienter vers une visite spécifique (jeu, quiz...) → "navigate_to_configuration". 6. Pour les détails d'un item → "GetItemDetailsGlobal". 7. Tu réponds UNIQUEMENT aux questions liées à cette visite et aux expériences ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle…), {fallbackInstruction}. + 8. {SearchKnowledgeRule} - NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats. - NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide. """; @@ -633,6 +688,7 @@ namespace ManagerService.Services messages.Add(new ChatMessage(ChatRole.User, request.Message)); NavigationActionDTO? navigation = null; + var sources = new List(); var tools = new List { @@ -817,6 +873,23 @@ namespace ManagerService.Services "GetItemDetailsGlobal", "Détails d'un lieu ou événement au niveau instance." ), + AIFunctionFactory.Create( + async (string question) => + { + // ConfigurationId est null à ce scope : il ne sert que de bonus de + // classement, le filtre dur reste l'instance. + var hits = await _vectorStore.SearchAsync( + request.InstanceId, request.ConfigurationId, request.Language, question); + + if (hits.Count == 0) + return "Aucun contenu du lieu ne traite de ce sujet."; + + RecordSources(sources, hits); + return string.Join("\n---\n", hits.Select(h => h.Text)); + }, + "SearchKnowledge", + "Cherche dans le contenu rédigé du lieu (descriptions, articles, étapes de parcours) ce qui traite d'un sujet. À utiliser pour toute question de fond sur les œuvres, l'histoire, les thématiques." + ), AIFunctionFactory.Create( (string configurationId, string configurationTitle) => { @@ -844,7 +917,7 @@ namespace ManagerService.Services reply = response.Text ?? ""; expectsReply = true; } - return new AiChatResponse { Reply = reply, Navigation = navigation, ExpectsReply = expectsReply, TokensUsed = response.Usage?.TotalTokenCount ?? 0 }; + return new AiChatResponse { Reply = reply, Navigation = navigation, ExpectsReply = expectsReply, TokensUsed = response.Usage?.TotalTokenCount ?? 0, Sources = sources.Count > 0 ? sources : null }; } catch (System.ClientModel.ClientResultException ex) { diff --git a/ManagerService/Services/IIngestionService.cs b/ManagerService/Services/IIngestionService.cs new file mode 100644 index 0000000..f567b55 --- /dev/null +++ b/ManagerService/Services/IIngestionService.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; + +namespace ManagerService.Services +{ + /// + /// Ré-indexation du contenu CMS pour le guide IA, exécutée en tâche de fond. + /// Les méthodes prennent un id, jamais un contenu : Hangfire sérialise les arguments + /// de job en JSON dans sa table Postgres, et le texte y serait recopié à chaque tentative. + /// + public interface IIngestionService + { + Task IngestSectionAsync(string sectionId); + + /// + /// Réindexe toutes les sections d'une instance et rend le nombre de morceaux produits. + /// Sert au rattrapage après un passage à un plan avec IA : sans lui, le client paie un + /// guide qui ne connaît rien de son contenu, puisque rien n'a été indexé tant qu'il + /// était sur un plan sans IA. + /// + Task BackfillInstanceAsync(string instanceId); + } +} diff --git a/ManagerService/Services/IVectorStoreService.cs b/ManagerService/Services/IVectorStoreService.cs new file mode 100644 index 0000000..dceec0c --- /dev/null +++ b/ManagerService/Services/IVectorStoreService.cs @@ -0,0 +1,48 @@ +using ManagerService.Data; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ManagerService.Services +{ + /// + /// Lecture et écriture des morceaux de contenu indexés pour le guide IA. + /// + public interface IVectorStoreService + { + /// + /// Remplace tous les morceaux d'un contenu. Le nombre de morceaux change d'une version + /// à l'autre : mettre à jour morceau par morceau laisserait des fantômes qui continueraient + /// d'alimenter les réponses avec du contenu supprimé. Une liste vide purge le contenu. + /// + Task ReplaceAsync(string instanceId, string configurationId, string contentId, + ContentSourceType contentType, IReadOnlyList chunks, + CancellationToken cancellationToken = default); + + /// + /// Cherche les morceaux les plus proches de la question. + /// Le seul filtre dur est l'instance. La langue ne filtre pas — un visiteur néerlandophone + /// doit retrouver une brochure qui n'existe qu'en français : le modèle d'embedding est + /// multilingue et la traduction se fait à la rédaction de la réponse. + /// et ne servent + /// qu'à bonifier le classement. + /// null retombe sur AI:SearchTopK. + /// + Task> SearchAsync(string instanceId, string currentConfigurationId, + string language, string query, int? topK = null, + CancellationToken cancellationToken = default); + + Task DeleteAsync(string contentId, ContentSourceType contentType, + CancellationToken cancellationToken = default); + } + + public record ContentChunk(string Text, int ChunkIndex, int? PageNumber, string Language); + + public record VectorSearchResult( + string ContentId, + ContentSourceType ContentType, + string Text, + int? PageNumber, + string Language, + double Score); +} diff --git a/ManagerService/Services/IngestionService.cs b/ManagerService/Services/IngestionService.cs new file mode 100644 index 0000000..525a099 --- /dev/null +++ b/ManagerService/Services/IngestionService.cs @@ -0,0 +1,236 @@ +using Hangfire; +using ManagerService.Data; +using ManagerService.Data.SubSection; +using ManagerService.DTOs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace ManagerService.Services +{ + [Queue(IngestionService.QueueName)] + // Le défaut de Hangfire est à 10 tentatives : sur un contenu que l'API d'embedding + // refuse, ça fait dix appels facturés pour le même échec. + [AutomaticRetry(Attempts = 2)] + public class IngestionService : IIngestionService + { + public const string QueueName = "ingestion"; + + /// + /// Un morceau doit tenir un sujet, pas une section entière : au-delà, le vecteur + /// moyenne tout et ne ressort plus sur aucune question précise. + /// + private const int MaxChunkChars = 1200; + + private readonly MyInfoMateDbContext _db; + private readonly IVectorStoreService _vectorStore; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public IngestionService(MyInfoMateDbContext db, IVectorStoreService vectorStore, + IConfiguration configuration, ILogger logger) + { + _db = db; + _vectorStore = vectorStore; + _configuration = configuration; + _logger = logger; + } + + public Task IngestSectionAsync(string sectionId) => IngestSectionCountingAsync(sectionId); + + /// Même travail, en rendant le nombre de morceaux — ce dont le backfill a besoin. + private async Task IngestSectionCountingAsync(string sectionId) + { + var stub = await _db.Sections.AsNoTracking().FirstOrDefaultAsync(s => s.Id == sectionId); + + // Section supprimée entre l'enqueue et l'exécution : il reste ses morceaux à retirer. + if (stub == null) + { + await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section); + return 0; + } + + // Re-vérifié ici et pas seulement à l'enqueue : le plan a pu changer entre les deux, + // et c'est ce contrôle-ci qui décide de l'appel facturé. + var hasAi = await _db.Instances + .Where(i => i.Id == stub.InstanceId) + .Select(i => i.AiTokensPerMonth) + .FirstOrDefaultAsync() > 0; + + // Sans IA on n'indexe plus, mais on ne purge pas : décidé le 2026-08-10. Ce qui est + // déjà indexé ne coûte que quelques Mo, l'usage est bloqué en amont, et un incident + // de paiement réglé le lendemain ne doit pas imposer de tout ré-embedder. + // Voir DOCS/v2/rag-indexing-trigger-decision.md. + if (!hasAi) + return 0; + + // Une section désactivée doit disparaître des réponses du guide, pas seulement de l'app. + if (!stub.IsActive) + { + await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section); + return 0; + } + + var section = await LoadWithChildrenAsync(stub); + var chunks = BuildChunks(section); + + await _vectorStore.ReplaceAsync(section.InstanceId, section.ConfigurationId, + section.Id, ContentSourceType.Section, chunks); + + _logger.LogInformation("Section {SectionId} indexée : {ChunkCount} morceaux", sectionId, chunks.Count); + return chunks.Count; + } + + public async Task BackfillInstanceAsync(string instanceId) + { + var sectionIds = await _db.Sections + .Where(s => s.InstanceId == instanceId) + .Select(s => s.Id) + .ToListAsync(); + + var chunkCount = 0; + foreach (var id in sectionIds) + chunkCount += await IngestSectionCountingAsync(id); + + _logger.LogInformation("Backfill instance {InstanceId} : {Count} sections, {ChunkCount} morceaux", + instanceId, sectionIds.Count, chunkCount); + return chunkCount; + } + + /// + /// Les collections filles vivent dans des tables séparées : sans chargement explicite, + /// GetEmbeddableText ne verrait ni les points d'une carte, ni les questions d'un quiz, + /// ni les étapes d'un parcours — et le guide ignorerait l'essentiel du contenu. + /// + private async Task
LoadWithChildrenAsync(Section stub) => stub.Type switch + { + SectionType.Map => await _db.Sections.AsNoTracking().OfType() + .Include(s => s.MapPoints) + .FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub, + + SectionType.Quiz => await _db.Sections.AsNoTracking().OfType() + .Include(s => s.QuizQuestions) + .FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub, + + SectionType.Agenda => await _db.Sections.AsNoTracking().OfType() + .Include(s => s.EventAgendas) + .FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub, + + SectionType.Event => await _db.Sections.AsNoTracking().OfType() + .Include(s => s.Programme).ThenInclude(b => b.MapAnnotations) + .Include(s => s.GlobalMapAnnotations) + .FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub, + + SectionType.Parcours => await _db.Sections.AsNoTracking().OfType() + .Include(s => s.GuidedPaths).ThenInclude(p => p.Steps).ThenInclude(st => st.QuizQuestions) + .FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub, + + // Les autres sous-types portent tout leur contenu dans leurs colonnes jsonb. + _ => stub + }; + + private List BuildChunks(Section section) + { + var languages = _configuration.GetSection("SupportedLanguages").Get>() + ?? new List(); + var chunks = new List(); + + // ChunkIndex court sur toute la section, langues confondues : la contrainte d'unicité + // porte sur (ContentType, ContentId, ChunkIndex), un compteur remis à zéro par langue + // ferait échouer l'insertion dès la deuxième. + foreach (var language in languages) + { + var text = WithoutPlaceholders(StripHtml(section.GetEmbeddableText(language))); + if (string.IsNullOrWhiteSpace(text)) + continue; + + foreach (var piece in Split(text)) + chunks.Add(new ContentChunk(piece, chunks.Count, null, language.ToUpperInvariant())); + } + + return chunks; + } + + /// + /// Gabarits posés par LanguageInit.Init à la création d'une section : « FR - Title », + /// « NL - Description »… Ils restent tant que le client n'a pas rempli la langue. + /// + /// + /// Mesuré sur l'instance de démo le 2026-08-10 : sans ce filtre, les cinq premiers + /// résultats d'une question en néerlandais étaient tous des « NL - Title NL - Description ». + /// Le bonus de langue suffit à les faire passer devant du vrai contenu français — soit + /// exactement le cas que la recherche cross-lingue devait servir. Une langue qui ne + /// contient que des gabarits ne produit donc plus aucun morceau. + /// + private static readonly Regex PlaceholderLine = + new(@"^[A-Za-z]{2}\s*-\s*(Title|Description|Content|Audio)$", RegexOptions.Compiled); + + private static string WithoutPlaceholders(string text) => + string.Join("\n", text + .Split('\n') + .Where(line => !PlaceholderLine.IsMatch(line.Trim()))); + + /// + /// Les champs riches sont saisis en HTML dans le back-office. Les balises se retrouvent + /// à l'identique dans tous les contenus : elles tirent les vecteurs vers un fond commun + /// et écrasent les écarts de score entre un morceau pertinent et un hors-sujet. + /// Remplacées par une espace et non par rien, sinon deux paragraphes accolés fusionnent + /// leurs mots — c'est ce qui distingue cette version de celle d'AssistantService, écrite + /// pour de l'affichage. + /// + private static string StripHtml(string text) => + WebUtility.HtmlDecode(Regex.Replace(text, "<[^>]+>", " ")); + + /// + /// Découpe sur les sauts de ligne — c'est déjà la frontière posée par JoinText entre + /// deux champs. + /// + private static IEnumerable Split(string text) + { + var current = new System.Text.StringBuilder(); + + foreach (var line in text.Split('\n').SelectMany(SplitLongLine)) + { + if (current.Length > 0 && current.Length + line.Length + 1 > MaxChunkChars) + { + yield return current.ToString().Trim(); + current.Clear(); + } + + current.Append(line).Append('\n'); + } + + if (current.Length > 0) + yield return current.ToString().Trim(); + } + + /// + /// Le contenu d'un article est du HTML sans saut de ligne : une fois les balises retirées, + /// il forme une seule ligne de toute la longueur du texte. Sans cette coupe il part d'un + /// bloc à l'embedding, dépasse l'entrée maximale du modèle, et l'échec emporte les + /// 49 autres morceaux de son lot — la section la plus riche du CMS ne s'indexe jamais. + /// La coupe cherche une fin de phrase avant de se rabattre sur une espace : trancher au + /// caractère près séparerait une phrase de son sujet. + /// + private static IEnumerable SplitLongLine(string line) + { + while (line.Length > MaxChunkChars) + { + var cut = line.LastIndexOf(". ", MaxChunkChars, StringComparison.Ordinal); + if (cut <= 0) cut = line.LastIndexOf(' ', MaxChunkChars); + if (cut <= 0) cut = MaxChunkChars - 1; + + yield return line.Substring(0, cut + 1).Trim(); + line = line.Substring(cut + 1).TrimStart(); + } + + yield return line; + } + } +} diff --git a/ManagerService/Services/SectionFactory.cs b/ManagerService/Services/SectionFactory.cs index 7586ba3..5c15378 100644 --- a/ManagerService/Services/SectionFactory.cs +++ b/ManagerService/Services/SectionFactory.cs @@ -4,6 +4,7 @@ using ManagerService.Data.SubSection; using ManagerService.DTOs; using Newtonsoft.Json; using System; +using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -11,6 +12,53 @@ namespace ManagerService.Services { public static class SectionFactory { + /// + /// Sous-type concret vide, collections initialisées. Section étant abstraite, + /// c'est le seul point d'entrée pour créer une section dont on n'a que le type. + /// + public static Section CreateEmpty(SectionType type) => type switch + { + SectionType.Agenda => new SectionAgenda + { + AgendaResourceIds = new List(), + EventAgendas = new List() + }, + SectionType.Article => new SectionArticle + { + ArticleContents = new List(), + ArticleContent = new List(), + ArticleAudioIds = new List() + }, + SectionType.Event => new SectionEvent + { + Programme = new List(), + ParcoursIds = new List() + }, + SectionType.Map => new SectionMap + { + MapMapType = MapTypeApp.hybrid, + MapTypeMapbox = MapTypeMapBox.standard, + MapMapProvider = MapProvider.Google, + MapZoom = 18, + MapPoints = new List(), + MapCategories = new List() + }, + SectionType.Menu => new SectionMenu { MenuSections = new List
() }, + SectionType.PDF => new SectionPdf { PDFOrderedTranslationAndResources = new List() }, + SectionType.Game => new SectionGame + { + GameMessageDebut = new List(), + GameMessageFin = new List() + }, + SectionType.Quiz => new SectionQuiz { QuizQuestions = new List() }, + SectionType.Slider => new SectionSlider { SliderContents = new List() }, + SectionType.Video => new SectionVideo { VideoSource = "" }, + SectionType.Weather => new SectionWeather(), + SectionType.Web => new SectionWeb { WebSource = "" }, + SectionType.Parcours => new SectionParcours { GuidedPaths = new List() }, + _ => throw new NotImplementedException($"Section type not handled: {type}") + }; + public static Section Create(JsonElement jsonElement, SectionDTO dto) { AgendaDTO agendaDTO = new AgendaDTO(); diff --git a/ManagerService/Services/VectorStoreService.cs b/ManagerService/Services/VectorStoreService.cs new file mode 100644 index 0000000..f1dff79 --- /dev/null +++ b/ManagerService/Services/VectorStoreService.cs @@ -0,0 +1,158 @@ +using ManagerService.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Pgvector; +using Pgvector.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace ManagerService.Services +{ + public class VectorStoreService : IVectorStoreService + { + /// + /// Départage deux traductions du même contenu, qui sortent à des distances quasi + /// identiques. Volontairement petit : un contenu réellement plus pertinent dans une + /// autre langue doit rester devant. + /// + private const double LanguageBonus = 0.05; + + /// Privilégie la visite en cours à pertinence comparable. + private const double ConfigurationBonus = 0.02; + + /// + /// On reclasse en mémoire, donc il faut plus de candidats que de résultats voulus, + /// sinon les bonus ne peuvent que réordonner ce que la distance brute avait déjà retenu. + /// + private const int OversampleFactor = 5; + private const int MinimumCandidates = 25; + + /// + /// Suffisant pour les questions d'un visiteur, qui portent sur un contenu précis. + /// À monter (`AI:SearchTopK`) si les réponses s'avèrent tronquées sur du volume réel — + /// se règle en même temps que hnsw.iterative_scan, sur le même test de charge. + /// + private const int DefaultTopK = 5; + + private readonly MyInfoMateDbContext _db; + private readonly IEmbeddingService _embeddingService; + private readonly int _defaultTopK; + + public VectorStoreService(MyInfoMateDbContext db, IEmbeddingService embeddingService, + IConfiguration configuration) + { + _db = db; + _embeddingService = embeddingService; + _defaultTopK = configuration.GetValue("AI:SearchTopK") ?? DefaultTopK; + } + + public async Task ReplaceAsync(string instanceId, string configurationId, string contentId, + ContentSourceType contentType, IReadOnlyList chunks, + CancellationToken cancellationToken = default) + { + var texts = (chunks ?? Array.Empty()) + .Where(c => !string.IsNullOrWhiteSpace(c.Text)) + .ToList(); + + // L'embedding est un appel réseau : le sortir de la transaction évite de tenir + // un verrou sur les lignes pendant plusieurs secondes, et un échec d'API laisse + // alors l'index précédent intact plutôt qu'un contenu vidé. + var vectors = await _embeddingService.EmbedBatchAsync( + texts.Select(c => c.Text).ToList(), cancellationToken); + + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + + await _db.ContentEmbeddings + .Where(e => e.ContentType == contentType && e.ContentId == contentId) + .ExecuteDeleteAsync(cancellationToken); + + var now = DateTime.UtcNow; + for (var i = 0; i < texts.Count; i++) + { + _db.ContentEmbeddings.Add(new ContentEmbedding + { + InstanceId = instanceId, + ConfigurationId = configurationId, + ContentId = contentId, + ContentType = contentType, + ChunkIndex = texts[i].ChunkIndex, + PageNumber = texts[i].PageNumber, + Language = texts[i].Language, + Text = texts[i].Text, + Embedding = new Vector(vectors[i]), + UpdatedAt = now + }); + } + + await _db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + + public async Task> SearchAsync(string instanceId, string currentConfigurationId, + string language, string query, int? topK = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + return new List(); + + var resultCount = topK ?? _defaultTopK; + var queryVector = new Vector(await _embeddingService.EmbedAsync(query, cancellationToken)); + var candidateCount = Math.Max(resultCount * OversampleFactor, MinimumCandidates); + + // pgvector applique le WHERE *après* le parcours de l'index HNSW : avec plusieurs + // instances dans la table, le filtre jette l'essentiel des candidats et la requête + // rend moins de lignes qu'il n'en existe. Le parcours itératif (pgvector 0.8+) + // relance le parcours jusqu'à en avoir assez après filtrage. + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + await _db.Database.ExecuteSqlRawAsync("SET LOCAL hnsw.iterative_scan = relaxed_order", cancellationToken); + + var candidates = await _db.ContentEmbeddings + .Where(e => e.InstanceId == instanceId) + .OrderBy(e => e.Embedding.CosineDistance(queryVector)) + .Take(candidateCount) + .Select(e => new + { + e.ContentId, + e.ContentType, + e.Text, + e.PageNumber, + e.Language, + e.ConfigurationId, + Distance = e.Embedding.CosineDistance(queryVector) + }) + .ToListAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + + return candidates + .Select(c => new VectorSearchResult( + c.ContentId, + c.ContentType, + c.Text, + c.PageNumber, + c.Language, + 1 - c.Distance + + (string.Equals(c.Language, language, StringComparison.OrdinalIgnoreCase) ? LanguageBonus : 0) + + (c.ConfigurationId != null && c.ConfigurationId == currentConfigurationId ? ConfigurationBonus : 0))) + .OrderByDescending(r => r.Score) + // Le même texte revient à l'identique quand il est répété dans le contenu — la + // description d'un lieu recopiée sur chacun de ses événements, par exemple. + // Mesuré sur l'instance de démo : trois des cinq résultats étaient le même + // paragraphe, au même score. Autant de place perdue dans le contexte du modèle. + .DistinctBy(r => r.Text) + .Take(resultCount) + .ToList(); + } + + public async Task DeleteAsync(string contentId, ContentSourceType contentType, + CancellationToken cancellationToken = default) + { + await _db.ContentEmbeddings + .Where(e => e.ContentType == contentType && e.ContentId == contentId) + .ExecuteDeleteAsync(cancellationToken); + } + } +} diff --git a/ManagerService/Services/VisitorQuestionPurgeService.cs b/ManagerService/Services/VisitorQuestionPurgeService.cs new file mode 100644 index 0000000..491f764 --- /dev/null +++ b/ManagerService/Services/VisitorQuestionPurgeService.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using ManagerService.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace ManagerService.Services +{ + /// + /// Supprime les questions posées au guide IA au-delà de 90 jours. + /// + /// Contrairement à , cette purge n'est **pas** + /// conditionnée à un réglage : la durée est un engagement pris dans les CGU (§8.4), pas + /// un paramètre commercial. Un job de conformité qui ne s'exécute pas faute de + /// configuration serait pire que pas de job du tout — il donnerait l'illusion du respect. + /// La clé VisitorQuestions:RetentionDays n'existe que pour raccourcir le délai en + /// recette ; absente, la valeur légale s'applique. + /// + /// ⚠️ **Dépendance non satisfaite à ce jour.** Les CGU annoncent que « les regroupements + /// par thème sont conservés au-delà, sous forme agrégée ». Or ThemeId est porté par + /// la ligne VisitorQuestion elle-même : supprimer la ligne supprime aussi l'agrégat. + /// Tant qu'une table d'agrégats distincte n'existe pas, la première purge fera perdre au + /// client tout son historique au 91ᵉ jour. Rien n'est encore collecté, donc le délai n'a + /// pas commencé à courir — mais la table doit exister avant la mise en service. + /// + public class VisitorQuestionPurgeService + { + /// Durée annoncée aux visiteurs et aux clients. Voir CGU §8.4. + public const int RetentionDays = 90; + + private readonly MyInfoMateDbContext _dbContext; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public VisitorQuestionPurgeService( + MyInfoMateDbContext dbContext, + IConfiguration configuration, + ILogger logger) + { + _dbContext = dbContext; + _configuration = configuration; + _logger = logger; + } + + public async Task PurgeAsync() + { + var configured = _configuration.GetValue("VisitorQuestions:RetentionDays"); + var retentionDays = configured > 0 ? configured : RetentionDays; + + var cutoff = DateTime.UtcNow.AddDays(-retentionDays); + var deleted = await _dbContext.VisitorQuestions + .Where(q => q.CreatedAt < cutoff) + .ExecuteDeleteAsync(); + + if (deleted > 0) + { + _logger.LogInformation( + "Purge RGPD des questions visiteurs : {Deleted} question(s) antérieure(s) au {Cutoff:yyyy-MM-dd} supprimée(s).", + deleted, cutoff); + } + } + } +} diff --git a/ManagerService/Startup.cs b/ManagerService/Startup.cs index 58fa482..4591124 100644 --- a/ManagerService/Startup.cs +++ b/ManagerService/Startup.cs @@ -190,6 +190,8 @@ namespace ManagerService .Build()); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Push Notifications var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"]; @@ -206,6 +208,7 @@ namespace ManagerService services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); var connectionString = Configuration.GetConnectionString("PostgresConnection"); @@ -215,7 +218,18 @@ namespace ManagerService .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() .UsePostgreSqlStorage(c => c.UseNpgsqlConnection(connectionString))); - services.AddHangfireServer(); + services.AddHangfireServer(options => options.ServerName = "default"); + + // Serveur séparé pour l'ingestion : le défaut lance min(nbCPU × 5, 20) workers, et + // autant d'extractions simultanées saturent la RAM d'un conteneur qui sert aussi l'API. + // Un serveur à part plutôt qu'une queue de plus, sinon les jobs d'ingestion occupent + // les workers de la file générale (sync agenda, météo, e-mails). + services.AddHangfireServer(options => + { + options.ServerName = IngestionService.QueueName; + options.Queues = new[] { IngestionService.QueueName }; + options.WorkerCount = 2; + }); var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); dataSourceBuilder.UseNetTopologySuite(); @@ -223,8 +237,14 @@ namespace ManagerService dataSourceBuilder.EnableDynamicJson(); var dataSource = dataSourceBuilder.Build(); - services.AddDbContext(options => + // Scoped, pas singleton : l'intercepteur accumule les sections touchées entre + // SavingChanges et SavedChanges. Partagé entre requêtes, deux clients se + // déclencheraient mutuellement des ré-indexations. + services.AddScoped(); + + services.AddDbContext((serviceProvider, options) => options.UseNpgsql(dataSource, o => o.UseNetTopologySuite().UseVector()) + .AddInterceptors(serviceProvider.GetRequiredService()) .EnableSensitiveDataLogging() .LogTo(Console.WriteLine, LogLevel.Information) ); @@ -306,6 +326,12 @@ namespace ManagerService s => s.PurgeAsync(), "0 3 * * *"); + // Actif sans condition : les 90 jours sont un engagement des CGU §8.4, pas un réglage. + RecurringJob.AddOrUpdate( + "visitor-questions-purge", + s => s.PurgeAsync(), + "30 3 * * *"); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); diff --git a/ManagerService/appsettings.json b/ManagerService/appsettings.json index 201c201..e81e19f 100644 --- a/ManagerService/appsettings.json +++ b/ManagerService/appsettings.json @@ -38,7 +38,8 @@ "SupportedLanguages": [ "FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK" ], "OpenWeatherApiKey": "d489973b4c09ddc5fb56bd7b9270bbef", "AI": { - "ApiKey": "AIzaSyCIf-mzp4Nzm5VwHL7LLzitt9z_bOOGMwc" + "ApiKey": "AIzaSyCIf-mzp4Nzm5VwHL7LLzitt9z_bOOGMwc", + "SearchTopK": 5 }, "Firebase": { "CredentialsPath": "firebase-adminsdk.json"