Compare commits

...

2 Commits

Author SHA1 Message Date
Thomas Fransolet
cfbaefb7f9 Deux tests sur ConversationId — le lien entre deux tours n'était couvert par rien
Le repli Guid.NewGuid() s'appliquait à chaque appel puisque aucun client n'envoyait
le champ : chaque question formait sa propre conversation en base, y compris deux
questions d'affilée dans le même chat. Les clients l'envoient depuis aujourd'hui,
ces tests fixent les deux branches.

dotnet test : 201 passés, 14 sautés, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:39:32 +02:00
Thomas Fransolet
118813af58 Les prompts du mode proactif ne sont plus journalisés comme des questions de visiteur
RecordVisitorQuestion écrivait Question = request.Message. Or le prompt d'un
déclenchement automatique 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… ») :
chaque passage devant une œuvre aurait ajouté une fausse question de visiteur
dans l'onglet Guide IA, l'écran fait pour montrer ce que les humains demandent.
Et HasAnswer se déduisant des sources, un prompt d'accueil sans citation aurait
gonflé le bloc ambre des questions sans réponse. Même famille que
WeatherSyncService noyant le journal d'audit.

AiChatRequest.IsAutoTriggered, et Chat qui saute la journalisation.

Tranché : ne rien journaliser, plutôt que marquer d'un drapeau. Une ligne
VisitorQuestion sert au rapport de trous de contenu et aux thèmes du lot J ; un
prompt machine n'alimente ni l'un ni l'autre, et le garder marqué obligerait
chaque futur agrégat à penser à l'exclure — la classe de panne qu'on venait de
fermer sur AuditedTypes.

Mais les jetons restent comptés : ces tours coûtent de l'argent réel au quota du
client, et un audioguide proactif peut en consommer beaucoup. Les 2 tests fixent
la paire — pas de ligne journalisée, compteur incrémenté — parce que c'est la
combinaison qui compte, pas chaque moitié isolément.

dotnet test : 199 passés, 14 sautés, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:14:01 +02:00
3 changed files with 107 additions and 1 deletions

View File

@ -105,6 +105,92 @@ namespace ManagerService.Tests.Controllers
Assert.IsType<ForbidResult>(result);
}
// ── 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.
/// </summary>
[Fact]
public async Task Chat_AutoTriggered_CountsTokensButLogsNoVisitorQuestion()
{
using var db = DbContextFactory.Create();
SeedAssistantInstance(db);
var request = MakeRequest("i1");
request.IsAutoTriggered = true;
await BuildController(db).Chat(request);
Assert.Empty(db.VisitorQuestions);
Assert.Equal(42, db.Instances.First().AiTokensThisMonth);
}
[Fact]
public async Task Chat_AskedByVisitor_LogsVisitorQuestion()
{
using var db = DbContextFactory.Create();
SeedAssistantInstance(db);
await BuildController(db).Chat(MakeRequest("i1"));
var logged = Assert.Single(db.VisitorQuestions);
Assert.Equal("Bonjour", logged.Question);
Assert.Equal(42, db.Instances.First().AiTokensThisMonth);
}
/// <summary>
/// Deux tours d'une même conversation partagent leur ConversationId — c'est le seul
/// lien entre eux côté serveur, l'historique étant reconstruit par le client.
/// ⚠️ Aucun client ne l'envoyait jusqu'au 2026-08-13 : le repli `Guid.NewGuid()`
/// s'appliquait à chaque appel, donc chaque question formait sa propre conversation.
/// </summary>
[Fact]
public async Task Chat_SameConversationId_LinksBothTurns()
{
using var db = DbContextFactory.Create();
SeedAssistantInstance(db);
var controller = BuildController(db);
var first = MakeRequest("i1");
first.ConversationId = "conv-1";
var second = MakeRequest("i1");
second.ConversationId = "conv-1";
await controller.Chat(first);
await controller.Chat(second);
Assert.Equal(2, db.VisitorQuestions.Count());
Assert.All(db.VisitorQuestions, q => Assert.Equal("conv-1", q.ConversationId));
}
[Fact]
public async Task Chat_NoConversationId_FallsBackToAGeneratedOne()
{
using var db = DbContextFactory.Create();
SeedAssistantInstance(db);
await BuildController(db).Chat(MakeRequest("i1"));
Assert.False(string.IsNullOrWhiteSpace(db.VisitorQuestions.First().ConversationId));
}
private static void SeedAssistantInstance(MyInfoMateDbContext db)
{
db.Instances.Add(new Instance
{
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiTokensPerMonth = 1_000, AiUsageMonthKey = DateTime.UtcNow.ToString("yyyy-MM")
});
db.ApplicationInstances.Add(new ApplicationInstance
{
Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = true,
Languages = new List<string>()
});
db.SaveChanges();
}
// ── QUOTA COUNTER ────────────────────────────────────────────────────
[Fact]

View File

@ -378,8 +378,16 @@ namespace ManagerService.Controllers
var result = await _assistantService.ChatAsync(request);
// Les jetons sont comptés dans tous les cas — un tour proactif coûte de
// l'argent réel au quota du client, l'exclure du compteur serait pire que
// le bruit qu'on retire juste en dessous.
RecordUsage(instance, result.TokensUsed);
RecordVisitorQuestion(request, result);
// 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)
RecordVisitorQuestion(request, result);
return Ok(result);
}

View File

@ -24,6 +24,18 @@ namespace ManagerService.DTOs
/// dates en toutes lettres, réponses courtes audio-friendly.
/// </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.
///
/// ⚠️ 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.
/// </summary>
public bool IsAutoTriggered { get; set; } = false;
}
public class AiChatMessage