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();