diff --git a/ManagerService.Tests/Controllers/AiControllerTests.cs b/ManagerService.Tests/Controllers/AiControllerTests.cs index bc28bf4..82a7463 100644 --- a/ManagerService.Tests/Controllers/AiControllerTests.cs +++ b/ManagerService.Tests/Controllers/AiControllerTests.cs @@ -108,25 +108,43 @@ namespace ManagerService.Tests.Controllers // ── JOURNALISATION DES QUESTIONS ───────────────────────────────────── /// - /// Un tour déclenché par le mode proactif ne doit pas apparaître dans - /// « Ce que demandent vos visiteurs » : sa « question » est une consigne que le - /// système s'est écrite à lui-même. Mais ses jetons sont bien consommés, donc - /// bien comptés — c'est la paire qui compte, pas chaque moitié isolément. + /// Un tour qui n'est pas une question de visiteur — prompt du mode proactif, ou + /// gestionnaire testant sa personnalité dans l'aperçu — ne doit pas apparaître dans + /// « Ce que demandent vos visiteurs ». Mais ses jetons sont bien consommés, donc + /// bien comptés : c'est la paire qui compte, pas chaque moitié isolément. /// [Fact] - public async Task Chat_AutoTriggered_CountsTokensButLogsNoVisitorQuestion() + public async Task Chat_NotAVisitorQuestion_CountsTokensButLogsNothing() { using var db = DbContextFactory.Create(); SeedAssistantInstance(db); var request = MakeRequest("i1"); - request.IsAutoTriggered = true; + request.IsVisitorQuestion = false; await BuildController(db).Chat(request); Assert.Empty(db.VisitorQuestions); Assert.Equal(42, db.Instances.First().AiTokensThisMonth); } + /// + /// Le défaut journalise : une app visiteur déjà publiée, qui n'envoie pas le champ, + /// doit continuer à alimenter le rapport. + /// + [Fact] + public async Task Chat_FieldAbsent_DefaultsToLogging() + { + using var db = DbContextFactory.Create(); + SeedAssistantInstance(db); + + await BuildController(db).Chat(new AiChatRequest + { + InstanceId = "i1", AppType = AppType.Tablet, Message = "Bonjour" + }); + + Assert.Single(db.VisitorQuestions); + } + [Fact] public async Task Chat_AskedByVisitor_LogsVisitorQuestion() { diff --git a/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs b/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs index 4801576..4f9a552 100644 --- a/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs +++ b/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs @@ -174,6 +174,29 @@ namespace ManagerService.Tests.Controllers Assert.Equal(1, summary.AppTypeDistribution["Tablet"]); } + /// + /// Le comptage des sessions vocales filtre sur une sous-chaîne du JSON de Metadata. + /// InMemory l'évalue côté client et passerait au vert quoi qu'il arrive : seul un + /// vrai PostgreSQL dit si le `Contains` se traduit en `LIKE` plutôt que d'échouer. + /// + [SkippableFact] + public void GetSummary_VoiceSessions_TranslatesToSql() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("stats_voice"); + Seed(db, advanced: true); + // s1 utilise le vocal une fois ; le reste de sa session est tactile. + db.VisitEvents.Add(Ev("e12", VisitEventType.SectionView, "s1", + DateTime.UtcNow.AddDays(-2), sectionId: "sect-a", metadata: "{\"voice\":true}")); + db.SaveChanges(); + + var summary = Summarize(db); + + Assert.Equal(1, summary.VoiceSessions); + Assert.Equal(2, summary.TotalSessions); + } + [SkippableFact] public void GetSummary_OnAnEmptyWindow_TranslatesWithoutFailing() { diff --git a/ManagerService.Tests/Controllers/StatsControllerTests.cs b/ManagerService.Tests/Controllers/StatsControllerTests.cs index 70cad0b..649ace9 100644 --- a/ManagerService.Tests/Controllers/StatsControllerTests.cs +++ b/ManagerService.Tests/Controllers/StatsControllerTests.cs @@ -119,6 +119,49 @@ namespace ManagerService.Tests.Controllers Assert.Equal(2, summary.TotalSessions); } + /// + /// Le vocal se compte en **sessions ayant utilisé le vocal**, pas en sessions dont + /// l'AppType vaudrait Voice : une même session mêle la voix et le tactile, c'est + /// tout l'objet du miroir. Ici la session A fait les deux et compte une fois. + /// + [Fact] + public void GetSummary_VoiceSessions_CountsSessionsThatUsedVoiceAtLeastOnce() + { + using var db = DbContextFactory.Create(); + db.VisitEvents.AddRange( + new VisitEvent { Id = "e1", InstanceId = "i1", SessionId = "session-A", EventType = VisitEventType.SectionView, Metadata = "{\"voice\":true}", Timestamp = DateTime.UtcNow }, + new VisitEvent { Id = "e2", InstanceId = "i1", SessionId = "session-A", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow }, + new VisitEvent { Id = "e3", InstanceId = "i1", SessionId = "session-B", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow } + ); + db.SaveChanges(); + + var result = BuildController(db).GetSummary("i1", null, null, null); + + var ok = Assert.IsType(result); + var summary = Assert.IsType(ok.Value); + Assert.Equal(1, summary.VoiceSessions); + Assert.Equal(2, summary.TotalSessions); + } + + [Fact] + public void GetSummary_VoiceSessions_ZeroWhenNothingIsVoice() + { + using var db = DbContextFactory.Create(); + db.VisitEvents.Add(new VisitEvent + { + Id = "e1", InstanceId = "i1", SessionId = "s1", + EventType = VisitEventType.SectionView, + Metadata = "{\"valid\":true}", + Timestamp = DateTime.UtcNow + }); + db.SaveChanges(); + + var result = BuildController(db).GetSummary("i1", null, null, null); + + var ok = Assert.IsType(result); + Assert.Equal(0, Assert.IsType(ok.Value).VoiceSessions); + } + [Fact] public void GetSummary_AppliesDateRange() { diff --git a/ManagerService/Controllers/AiController.cs b/ManagerService/Controllers/AiController.cs index 2496939..7aead48 100644 --- a/ManagerService/Controllers/AiController.cs +++ b/ManagerService/Controllers/AiController.cs @@ -383,10 +383,11 @@ namespace ManagerService.Controllers // le bruit qu'on retire juste en dessous. RecordUsage(instance, result.TokensUsed); - // Mais un tour que le système s'est déclenché à lui-même n'est pas une - // question de visiteur : il ne va ni dans « Ce que demandent vos visiteurs », - // ni dans les trous de contenu, ni dans les thèmes du lot J. - if (!request.IsAutoTriggered) + // Mais un tour qui n'est pas une question de visiteur — prompt que le + // système s'est écrit à lui-même, ou gestionnaire qui teste sa personnalité + // dans l'aperçu — ne va ni dans « Ce que demandent vos visiteurs », ni dans + // les trous de contenu, ni dans les thèmes du lot J. + if (request.IsVisitorQuestion) RecordVisitorQuestion(request, result); return Ok(result); diff --git a/ManagerService/Controllers/StatsController.cs b/ManagerService/Controllers/StatsController.cs index c204452..778d416 100644 --- a/ManagerService/Controllers/StatsController.cs +++ b/ManagerService/Controllers/StatsController.cs @@ -202,6 +202,16 @@ namespace ManagerService.Controllers .GroupBy(t => t) .ToDictionary(g => g.Key, g => g.Count()); + // Sessions ayant utilisé le vocal. Le marqueur est dans le JSON de Metadata, + // donc hors de portée d'un opérateur SQL portable : on filtre en base sur la + // sous-chaîne — ce qui élimine l'immense majorité des lignes — et on ne + // remonte que SessionId, jamais la fenêtre entière. + summary.VoiceSessions = eventsQuery + .Where(e => e.Metadata != null && e.Metadata.Contains("\"voice\":true")) + .Select(e => e.SessionId) + .Distinct() + .Count(); + // Les statistiques avancées ne sont pas seulement masquées : elles ne sont // plus calculées. Les six requêtes ci-dessous ne partent pas du tout sur un // plan qui n'y donne pas droit. diff --git a/ManagerService/DTOs/AiChatDTO.cs b/ManagerService/DTOs/AiChatDTO.cs index 7d73430..d6fe183 100644 --- a/ManagerService/DTOs/AiChatDTO.cs +++ b/ManagerService/DTOs/AiChatDTO.cs @@ -25,17 +25,22 @@ namespace ManagerService.DTOs /// public bool IsVoice { get; set; } = false; /// - /// true = le tour a été déclenché par le système, pas demandé par le visiteur — - /// c'est le cas du mode proactif, où l'approche d'un point d'intérêt fait parler - /// le guide tout seul. + /// false = ce tour n'est pas une question posée par un visiteur, et ne doit donc + /// pas être journalisé dans VisitorQuestion. Deux cas aujourd'hui : + /// + /// le mode proactif, dont la « question » est une consigne que le + /// système s'écrit à lui-même (« Tu es un guide audio de musée. Le visiteur vient + /// d'entrer dans la zone X… ») ; + /// l'aperçu de conversation du Guide IA, où c'est le gestionnaire qui + /// teste sa personnalité — sans quoi il pollue son propre rapport. + /// /// - /// ⚠️ Ces tours ne sont **pas journalisés** dans VisitorQuestion : leur - /// « question » est une consigne que le système s'est écrite à lui-même - /// (« Tu es un guide audio de musée. Le visiteur vient d'entrer dans la zone X… »), - /// et l'onglet Guide IA existe pour montrer ce que les **humains** demandent. - /// Leurs jetons, eux, restent comptés : ils sont réellement consommés. + /// ⚠️ Les jetons restent comptés dans les deux cas : ils sont réellement consommés. + /// + /// ⚠️ Défaut à true, et c'est délibéré : un client qui n'envoie pas le champ + /// journalise, ce qui est le bon comportement pour les apps visiteur déjà publiées. /// - public bool IsAutoTriggered { get; set; } = false; + public bool IsVisitorQuestion { get; set; } = true; } public class AiChatMessage diff --git a/ManagerService/DTOs/StatsSummaryDTO.cs b/ManagerService/DTOs/StatsSummaryDTO.cs index bfa0912..5493216 100644 --- a/ManagerService/DTOs/StatsSummaryDTO.cs +++ b/ManagerService/DTOs/StatsSummaryDTO.cs @@ -11,6 +11,22 @@ namespace ManagerService.DTOs public List VisitsByDay { get; set; } = new(); public Dictionary LanguageDistribution { get; set; } = new(); public Dictionary AppTypeDistribution { get; set; } = new(); + + /// + /// Nombre de sessions ayant **utilisé le vocal au moins une fois**. + /// + /// ⚠️ Ce n'est pas une entrée d', et c'est délibéré. + /// Le vocal n'est pas une plateforme mais un mode d'interaction : un visiteur passe + /// des lunettes à l'écran dans une même session — c'est tout l'objet du miroir. Or + /// la distribution compte **une entrée par session**, tranchée sur l'événement le + /// plus ancien : commencer au doigt puis mettre les lunettes aurait rendu le vocal + /// invisible, et l'inverse aurait compté toute la navigation tactile comme vocale. + /// Le KPI devient « part des sessions ayant utilisé le vocal », qui est vrai. + /// + /// C'est la conception que VisitorQuestion applique déjà : un booléen + /// IsVoice **à côté** de l'AppType, pas une valeur d'AppType. + /// + public int VoiceSessions { get; set; } public List TopPois { get; set; } = new(); public List TopAgendaEvents { get; set; } = new(); public List QuizStats { get; set; } = new();