From e3ef4137a5cd48230b855565449c049ad77baf59 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Thu, 13 Aug 2026 14:32:54 +0200 Subject: [PATCH] =?UTF-8?q?Canal=20vocal=20des=20stats,=20et=20IsAutoTrigg?= =?UTF-8?q?ered=20renomm=C3=A9=20IsVisitorQuestion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VoiceSessions : nombre de sessions ayant utilisé le vocal au moins une fois, lu dans le JSON de Metadata. Volontairement hors d'AppTypeDistribution — le vocal n'est pas une plateforme mais un mode d'interaction : un visiteur passe des lunettes à l'écran dans une même session, or la distribution compte une entrée par session tranchée sur l'événement le plus ancien. Le chiffre aurait dépendu de l'ordre dans lequel le visiteur a touché ses appareils. Colonne Metadata déjà existante : aucune migration, le gel du lot B tient. Le test InMemory ne prouve rien ici — le provider évalue le filtre côté client, donc une requête intraduisible passerait au vert. Un test Postgres l'accompagne. IsAutoTriggered devient IsVisitorQuestion (défaut true). Le nom d'hier ne couvrait qu'un des deux cas : le vrai sens n'est pas « déclenché automatiquement » mais « ce tour n'est pas une question de visiteur », ce qui vaut aussi pour l'aperçu de conversation, où c'est le gestionnaire qui teste sa personnalité. Renommé pendant qu'un seul commit en dépendait. Le défaut à true est délibéré : une app visiteur publiée qui n'envoie pas le champ continue de journaliser, et un test le fixe. dotnet test : 204 passés, 14 sautés, 0 échec. Co-Authored-By: Claude Opus 5 --- .../Controllers/AiControllerTests.cs | 30 ++++++++++--- .../StatsControllerPostgresTests.cs | 23 ++++++++++ .../Controllers/StatsControllerTests.cs | 43 +++++++++++++++++++ ManagerService/Controllers/AiController.cs | 9 ++-- ManagerService/Controllers/StatsController.cs | 10 +++++ ManagerService/DTOs/AiChatDTO.cs | 23 ++++++---- ManagerService/DTOs/StatsSummaryDTO.cs | 16 +++++++ 7 files changed, 135 insertions(+), 19 deletions(-) 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();