Canal vocal des stats, et IsAutoTriggered renommé IsVisitorQuestion
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 <noreply@anthropic.com>
This commit is contained in:
parent
cfbaefb7f9
commit
e3ef4137a5
@ -108,25 +108,43 @@ namespace ManagerService.Tests.Controllers
|
||||
// ── JOURNALISATION DES QUESTIONS ─────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le défaut journalise : une app visiteur déjà publiée, qui n'envoie pas le champ,
|
||||
/// doit continuer à alimenter le rapport.
|
||||
/// </summary>
|
||||
[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()
|
||||
{
|
||||
|
||||
@ -174,6 +174,29 @@ namespace ManagerService.Tests.Controllers
|
||||
Assert.Equal(1, summary.AppTypeDistribution["Tablet"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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()
|
||||
{
|
||||
|
||||
@ -119,6 +119,49 @@ namespace ManagerService.Tests.Controllers
|
||||
Assert.Equal(2, summary.TotalSessions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<OkObjectResult>(result);
|
||||
var summary = Assert.IsType<StatsSummaryDTO>(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<OkObjectResult>(result);
|
||||
Assert.Equal(0, Assert.IsType<StatsSummaryDTO>(ok.Value).VoiceSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_AppliesDateRange()
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -25,17 +25,22 @@ namespace ManagerService.DTOs
|
||||
/// </summary>
|
||||
public bool IsVoice { get; set; } = false;
|
||||
/// <summary>
|
||||
/// 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 <c>VisitorQuestion</c>. Deux cas aujourd'hui :
|
||||
/// <list type="bullet">
|
||||
/// <item>le <b>mode proactif</b>, 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… ») ;</item>
|
||||
/// <item>l'<b>aperçu de conversation</b> du Guide IA, où c'est le gestionnaire qui
|
||||
/// teste sa personnalité — sans quoi il pollue son propre rapport.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// ⚠️ Ces tours ne sont **pas journalisés** dans <c>VisitorQuestion</c> : 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.
|
||||
///
|
||||
/// ⚠️ <b>Défaut à true, et c'est délibéré</b> : un client qui n'envoie pas le champ
|
||||
/// journalise, ce qui est le bon comportement pour les apps visiteur déjà publiées.
|
||||
/// </summary>
|
||||
public bool IsAutoTriggered { get; set; } = false;
|
||||
public bool IsVisitorQuestion { get; set; } = true;
|
||||
}
|
||||
|
||||
public class AiChatMessage
|
||||
|
||||
@ -11,6 +11,22 @@ namespace ManagerService.DTOs
|
||||
public List<DayStatDTO> VisitsByDay { get; set; } = new();
|
||||
public Dictionary<string, int> LanguageDistribution { get; set; } = new();
|
||||
public Dictionary<string, int> AppTypeDistribution { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de sessions ayant **utilisé le vocal au moins une fois**.
|
||||
///
|
||||
/// ⚠️ Ce n'est pas une entrée d'<see cref="AppTypeDistribution"/>, 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 <c>VisitorQuestion</c> applique déjà : un booléen
|
||||
/// <c>IsVoice</c> **à côté** de l'AppType, pas une valeur d'AppType.
|
||||
/// </summary>
|
||||
public int VoiceSessions { get; set; }
|
||||
public List<PoiStatDTO> TopPois { get; set; } = new();
|
||||
public List<AgendaEventStatDTO> TopAgendaEvents { get; set; } = new();
|
||||
public List<QuizStatDTO> QuizStats { get; set; } = new();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user