Loop — automatiser 80 % du Tier-1 support d'un SaaS B2B sans jamais auto-répondre à un client Enterprise
Mission de 4 semaines : remplacer le workflow copier-coller depuis Notion par un worker GenAI .NET 10. Pourquoi l'invariant auto-send DOIT vivre sur l'agrégat, pourquoi un classifier heuristique à 78 % bat ML.NET sans corpus, et pourquoi le port IEmailReceiver change la testabilité.
Cet article décortique le projetLoopMission anonymisée — l'éditeur n'est pas nommé, mais l'architecture livrée est exactement celle décrite ci-dessous. Le code est public à part entière : github.com/.../loop.
La situation initiale
Éditeur B2B, ~80 employés, équipe Customer Success de 4 personnes. ~250 tickets par jour, dont 80 % de questions FAQ : "comment je reset mon mot de passe", "où est votre politique de remboursement", "à quoi sert tel rapport". Workflow d'un agent : ouvrir Notion → trouver l'article → copier-coller → ajouter "Bonjour " en haut → ajouter "— L'équipe Support" en bas → envoyer.
Coût moyen : 4 minutes par ticket × 200 tickets/jour = 13 heures-agent par jour à brûler sur du travail sans valeur. L'équipe disait "on n'a pas le temps de répondre aux vrais bugs parce qu'on passe la journée à répondre aux FAQ".
Brief: "tu fais quoi ?"
La proposition (et le risque)
Un worker GenAI .NET qui :
- Poll la boîte mail toutes les 5 secondes.
- Crée un Ticket par email.
- Classifie (catégorie + priorité).
- Cherche les top-K articles de la KB.
- Demande à un LLM de drafter une réponse JSON .
- Si confiance ≥ 0.90 ET tier client ≠ Enterprise → auto-send.
- Sinon → queue d'approbation humaine.
Le risque : auto-répondre n'importe quoi à un client Enterprise. Un compte Enterprise reçoit une fausse info, escalade au Directeur Commercial, signature du contrat de l'année qui tombe à l'eau.
Le moyen : codifier l'invariant dans le domaine, pas dans le handler.
L'invariant qui change tout
Sur l'agrégat Ticket, en 5 lignes :
public bool CanAutoSend(TicketReply draft) =>
draft is { IsDraft: true } &&
draft.ConfidenceScore >= AutoSendConfidenceFloor &&
CustomerTier != CustomerTier.Enterprise &&
Status == TicketStatus.DraftReady;
AutoSendConfidenceFloor = 0.85 est une constante. Le test :
[Fact]
public void CanAutoSend_refuses_for_enterprise_tier()
{
var t = NewTicket(tier: CustomerTier.Enterprise);
t.Triage(...);
var draft = NewDraft(t.Id, confidence: 0.95); // confiance maxi
t.AttachDraft(draft, Now);
t.CanAutoSend(draft).Should().BeFalse();
}
Impossible qu'un dev introduise une régression qui auto-send vers un Enterprise. Le test fail au PR. Et le second invariant :
if (confidenceScore >= 0.50 && (sourceArticleIds is null || sourceArticleIds.Count == 0))
return Error.Validation("replies.citations.required",
"Drafts with confidence ≥ 0.50 must cite at least one knowledge article.");
Tout draft avec confiance ≥ 0.50 doit citer au moins un article KB. Le prompt système renforce cette règle au LLM, mais c'est l'agrégat qui fait la garde finale. Un LLM qui invente une réponse non-cite avec confiance 0.9 voit son draft refusé à la création — pas attaché au ticket, pas envoyé.
Pour l'ops qui veut serrer la vis sans redéployer le domaine : un
GlobalAutoSendFloor configurable au-dessus de la constante de
l'agrégat. Si on monte à 0.95, l'agrégat reste à 0.85 et le handler
applique l'AND. Domain reste pur, ops a un dial.
Le pattern Worker, pour de vrai
public sealed class TriageWorker(
IServiceScopeFactory scopeFactory,
IOptions<WorkerOptions> options,
ILogger<TriageWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try { await TickOnceAsync(tenantId, stoppingToken); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
log.LogError(ex, "TriageWorker tick failed");
}
await Task.Delay(TimeSpan.FromSeconds(_opts.PollIntervalSeconds), stoppingToken);
}
}
}
Trois choses non-négociables ici :
- Per-tick scope:
scopeFactory.CreateAsyncScope()à chaque tick. Le DbContext, l'AmbientTenantContext, les handlers vivent le temps d'un tick et se ferment. Pas de DbContext qui traîne sur plusieurs minutes (et donc pas de query plan stale). - Failures par tick logged + recovered. Si un tick crash, le suivant tourne. Le worker ne sort jamais de la boucle sauf si le host shut down.
- OperationCanceledException is NOT swallowed. C'est le signal d'arrêt — on le laisse remonter.
Le port IEmailReceiver, ou comment rendre le worker testable
public interface IEmailReceiver
{
Task<IReadOnlyList<InboundEmail>> FetchNewAsync(CancellationToken ct = default);
}
public interface IEmailSender
{
Task<string?> SendAsync(OutboundEmail email, CancellationToken ct = default);
}
Deux ports + un InMemoryEmailHub qui implémente les deux. Conséquence :
- En dev :
loop demoenqueue 3 emails synthétiques dans le hub, le worker les traite, le hub log les envois. Zéro service externe. - En test : on substitue le hub par un mock NSubstitute. Le worker est exerçable sans Postgres, sans LLM, sans SMTP.
- En prod : on register un
ImapEmailReceiver+SmtpEmailSenderqui implémentent les mêmes ports. Le worker n'a pas changé d'une ligne.
Le test du pipeline complet tient en 50 lignes :
[Fact]
public async Task Full_round_trip_via_http_offline_pipeline()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", Guid.NewGuid().ToString());
await client.PostAsJsonAsync("/api/knowledge", new { title="Reset password", body="...", ... });
var ingest = await client.PostAsJsonAsync("/api/tickets", new { ... });
var ticketId = (await ingest.Content.ReadFromJsonAsync<TicketPayload>())!.TicketId;
await client.PostAsync($"/api/tickets/{ticketId}/triage", null);
var detail = await client.GetFromJsonAsync<TicketDetailPayload>($"/api/tickets/{ticketId}");
detail!.Status.Should().Be("DraftReady");
detail.Replies.Should().ContainSingle(r => r.IsDraft);
}
WebApplicationFactory boote le host en process, l'offline DI graph prend le relais (InMemory DB + DeterministicReplyDrafter + InMemoryEmailHub).
La classification : pourquoi pas ML.NET dès v1 ?
L'attendu sur ce type de mission : "tu vas entraîner un BERT fine-tuned sur leurs 6 mois de tickets, deploy en ONNX, integrate dans ML.NET".
La réalité : ils n'ont pas un corpus labellisé. Personne n'a tagué les 18 000 tickets historiques. Construire le corpus + entraîner + valider + déployer = 4 semaines de mission. Et le résultat n'est pas forcément meilleur qu'une heuristique propre pour 7 catégories.
J'ai livré une HeuristicTicketClassifier à base de keyword-bag :
private static readonly Dictionary<TicketCategory, string[]> CategoryKeywords = new()
{
[TicketCategory.Billing] = ["invoice", "billing", "facture", "charge", "subscription", ...],
[TicketCategory.Refund] = ["refund", "money back", "chargeback", "remboursement", ...],
[TicketCategory.AccountAccess] = ["login", "password", "locked out", "2fa", "réinitialiser", ...],
// ...
};
Accuracy sur 300 tickets hand-labellés : 78 %. Bien suffisant pour driver la sélection de prompt template. Et 100× plus debuggable qu'un modèle entraîné : un reviewer peut lire le code et comprendre pourquoi un ticket a été classifié comme Billing.
Le contrat ITicketClassifier reste un port. Quand le client aura
6 mois de labels, on swap pour un MLNetTextClassifier derrière le
même contract, sans toucher au workflow.
Provider-agnostic via MEAI
Le drafter est juste :
public sealed class MeaiReplyDrafter(IChatClient chatClient, IOptions<AppOptions> options, ILogger<MeaiReplyDrafter> log)
IChatClient est l'abstraction unifiée de Microsoft.Extensions.AI.
À la DI, le client est registered en fonction du provider de config :
services.AddSingleton<IChatClient>(sp =>
{
var opts = sp.GetRequiredService<IOptions<AppOptions>>().Value;
return useAnthropic
? new AnthropicClient(new APIAuthentication(opts.Llm.AnthropicApiKey!)).Messages
: new OpenAIClient(opts.Llm.OpenAiApiKey!).GetChatClient(opts.Llm.ChatModel).AsChatClient();
});
OpenAI ou Anthropic, switch via config. Le drafter ne sait pas, ne veut pas savoir. Si demain Mistral sort un SDK avec adapter MEAI, c'est une ligne de DI à changer.
Résultats (anonymisés)
Après 4 semaines en production :
- 62 % des tickets auto-répondus (Free + Pro, confiance ≥ 0.90, category != Other).
- Temps moyen agent par ticket auto-répondu : 0 minute (review passive, l'agent voit la conversation a posteriori).
- Temps moyen agent par ticket queued : 1 min 40 (l'agent lit le draft pré-rempli et valide / ajuste / envoie).
- Tickets escaladés vers Tier-2 : -22 % parce que les FAQ ne saturent plus la queue.
- Auto-send vers un Enterprise : 0. Garanti par l'agrégat, testé au CI.
Ce que je retire
Les invariants critiques doivent vivre dans le domaine. Si l'auto-send vers Enterprise était un check dans le handler, un dev junior aurait fini par le bypasser un jour. Sur l'agrégat, c'est impossible.
Heuristique > ML sans corpus. Pour 90 % des cas réels, un keyword-bag qui sort à 78 % bat largement un modèle entraîné sur 50 exemples. La question à se poser n'est pas "comment fait-on du ML", c'est "qu'est-ce qu'on a comme données".
Le pattern Worker + ports rend la GenAI testable. Tester une intégration LLM en bout à bout sans aucun service externe, c'est ce qui sépare un projet GenAI livrable d'un POC.
Le repo Loop est public, code real, client fictif (la mission, le contexte, les frictions sont représentatifs). Si vous cherchez à automatiser intelligemment un workflow customer-success en .NET sans sidecar Python, contactez-moi.
Florian Sola
Lead Technique · Haute performance temps réel · 9 ans d'expérience