J'ai construit Vouch en .NET 10 — la preuve que GenAI native sans sidecar Python, ça marche
Vouch — moteur de réponse automatique aux questionnaires SOC 2 / ISO 27001 — full .NET 10, MEAI, pgvector. Pourquoi j'ai refusé le sidecar Python, comment l'invariant cité-ou-rien est codé dans le domaine, et ce que j'en retire pour positionner un profil .NET face à des architectures hybrides.
Cet article décortique le projetVouchLe problème qui m'a fait construire Vouch
Tu vends un SaaS B2B. Chaque deal de plus de 50 k€ arrive avec un questionnaire sécurité — SOC 2, ISO 27001, CAIQ v4, SIG, ou la dernière variante maison du prospect. 200 lignes, parfois 400. Le pré-sales le passe au RSSI, le RSSI passe 4 à 12 heures à copier-coller depuis les politiques internes, et 30 % des réponses partent quand même avec une erreur factuelle parce que la dernière version du policy SOP a été oubliée.
J'ai vu ce workflow trois fois en mission. À chaque fois, la même question : pourquoi un humain re-écrit-il 80 % des mêmes réponses, à la main, avec un risque d'hallucination plus élevé qu'un LLM bien câblé ?
Alors j'ai construit Vouch. Le pitch en une ligne : upload tes politiques une fois, le moteur remplit n'importe quel questionnaire en citant chaque source. Et surtout, sans aucun sidecar Python.
Décision n° 1 : 100 % .NET, ou rien
L'écosystème GenAI 2024–2026 a un biais Python. Embeddings ? sentence-transformers. RAG ? LangChain. Orchestration ? LlamaIndex. Tout est en Python. Et donc tout le monde construit son SaaS en .NET avec un sidecar Python à côté.
J'ai refusé pour trois raisons :
- Coût opérationnel. Deux runtimes, deux pipelines CI, deux registres de dépendances, deux processus d'astreinte. Pour une boîte qui n'a pas encore 10 clients payants, c'est de la dette pure.
- Positionnement. Mon CV est .NET. Si je livre un produit .NET avec un sidecar Python qui fait tout le travail intéressant, je vends quoi exactement ?
- Maturité réelle.
Microsoft.Extensions.AI(MEAI) est sorti officiel fin 2024. L'API est propre, multi-provider, et Anthropic + OpenAI implémententIChatClientdirectement. pgvector a un binding .NET officiel. ML.NET tourne pour la classification. La parité fonctionnelle est là — il fallait juste quelqu'un pour le démontrer publiquement.
Vouch est ce démonstrateur. Aucune ligne de Python. Tout passe par MEAI ou les SDK officiels en C#.
Architecture : DDD strict, layers qui compilent isolés
J'ai vu trop de projets "Clean Architecture" où le domaine importe Microsoft.EntityFrameworkCore parce que "c'est pratique pour les attributs". Sur Vouch, la règle est non-négociable : Vouch.Domain compile sans aucune dépendance externe.
$ dotnet build src/Vouch.Domain
Vouch.Domain -> Vouch.Domain.dll
Build succeeded.
Zéro NuGet, zéro using Microsoft.*, zéro abstraction infrastructure qui fuit. Les agrégats sont du C# pur :
public sealed class Answer : Entity<AnswerId>
{
public static Result<Answer> CreateDraft(
string text,
ConfidenceScore confidence,
string modelIdentifier,
IReadOnlyList<AnswerCitation> citations,
int revisionNumber = 1)
{
if (string.IsNullOrWhiteSpace(text))
return Error.Validation("answers.text.required", "...");
// L'invariant qui paye le ticket d'entrée
if (confidence.Band == ConfidenceBand.High && citations.Count == 0)
return DomainErrors.Answers.NoCitationsForHighConfidence;
return new Answer(/* ... */);
}
}
Au-dessus : Vouch.Application n'a que FluentValidation + Microsoft.Extensions.Logging.Abstractions + les options. Vouch.Infrastructure porte EF Core, pgvector, MEAI, les parsers. Vouch.Web et Vouch.Cli sont des hôtes qui composent la racine DI. Personne ne saute une couche.
Résultat concret : quand je veux remplacer pgvector par Qdrant, je touche un seul fichier (EfVectorSearch.cs) et ses tests. Quand je veux passer d'OpenAI à Anthropic, je touche DependencyInjection.RegisterLlmAdapters et c'est fini.
L'invariant qui change tout : cité-ou-rien
Le risque numéro un d'un produit comme Vouch, c'est l'hallucination. Un LLM qui invente une réponse de SOC 2, c'est pire que ne rien remplir : le pré-sales fait confiance, l'envoie, et le compliance officer du prospect détecte l'incohérence en 15 secondes. Deal mort.
Plutôt que de mettre ça dans une doc ou un guard côté UI, je l'ai codé dans l'agrégat :
if (confidence.Band == ConfidenceBand.High && citations.Count == 0)
return DomainErrors.Answers.NoCitationsForHighConfidence;
Impossible de construire une réponse à haute confiance sans citation. Pas un check au runtime côté contrôleur — un invariant du domaine. Tout code path qui produit une Answer passe par Answer.CreateDraft. Pas de bypass possible.
Côté générateur, le prompt système contient :
If no excerpt supports the question, output "INSUFFICIENT_EVIDENCE"
— do not improvise.
Quand le LLM répond INSUFFICIENT_EVIDENCE, le générateur crée une Answer à confiance 0.05 sans citation. Le reviewer humain voit la ligne flaggée et tranche.
C'est ce genre de discipline qu'un product manager peut promettre à un prospect : "regarde le code, c'est impossible que tu reçoives une réponse cited 99 % sans source attachée".
Le pipeline complet
fixtures/policies/*.{pdf,docx,md,html}
│
▼ PdfPig / DocumentFormat.OpenXml / regex split
segments
│
▼ TokenAwareChunker (500 tokens × 50 overlap)
chunks
│
▼ OpenAI embeddings (ou DeterministicEmbeddingService offline)
vectors
│
▼ pgvector (HNSW, vector_cosine_ops, m=16, ef_construction=64)
indexed
fixtures/questionnaire.xlsx
│
▼ XlsxQuestionnaireImporter (heuristique CAIQ/SIG)
questions
│
▼ HeuristicQuestionClassifier (type + framework)
classified
│
▼ Per question :
embed → vector search top-K
→ MEAI ChatClient (JSON mode)
→ ResilientChatClient (Polly retry + timeout)
→ Answer.CreateDraft (invariant cité-ou-rien)
→ Question.AttachDraft
→ AuditEvent.AnswerGenerated
Linéaire. Lisible. Chaque flèche est un port I* côté Application avec une impl côté Infrastructure. Tous les ports sont mockés dans les tests handlers ; les vrais adapters sont testés en infra unit tests.
Multi-tenant : query filter automatique
Une banalité qu'on rate dans 1 SaaS B2B sur 2 : oublier le filtre par tenant sur une requête, et l'audit log devient une histoire de fuite de données.
Vouch a un seul mécanisme :
modelBuilder.Entity<Policy>().HasQueryFilter(
p => !_tenantContext.IsSet || p.TenantId == _tenantContext.CurrentTenantId);
Le !_tenantContext.IsSet est le bypass — utilisé uniquement pour la création de tenant (chicken-and-egg) et les migrations. Tout autre code chemin part avec un tenant lié au scope DI.
Et surtout : ce filter marche aussi bien avec Npgsql qu'avec le provider InMemory. Pourquoi ? Parce que j'ai refusé l'astuce Expression.Invoke qui compose deux lambdas — InMemory ne sait pas la traduire. Une expression inline, c'est moins joli mais ça tourne partout.
Le fallback offline qui change la démo
Le plus gros frein à un projet GenAI sur ton portfolio, c'est : "ouais cool, mais comment je le fais tourner sans clé OpenAI à 200 $/mois ?"
Vouch a deux fallbacks pragmatiques qui se déclenchent à la config :
- Sans connection string Postgres → EF Core In-Memory +
InMemoryVectorSearchjoint au DbContext pour les excerpts. - Sans clé API OpenAI/Anthropic →
DeterministicEmbeddingService(SHA-256 de trigrammes, L2-normalisé) +DeterministicAnswerGenerator(stitch des top-3 excerpts).
La qualité est volontairement faible, mais le pipeline complet tourne. Un recruteur qui clone le repo et lance :
dotnet run --project src/Vouch.Cli -- demo
obtient un XLSX rempli en 30 secondes, sans avoir rien configuré. Cette friction-zéro change tout. Plus jamais "il faudrait que j'installe Postgres et que j'aille chercher une clé pour voir le projet tourner".
Ce que ça m'a appris sur GenAI native en .NET en 2026
MEAI est prêt pour la production. IChatClient + IEmbeddingGenerator + les DelegatingChatClient pour décorer (mon ResilientChatClient est 60 lignes incluant Polly). Tous les SDK officiels (OpenAI, Anthropic, Ollama, Azure OpenAI) l'implémentent. Le switch de provider est un changement de DI.
pgvector + EF Core est sous-utilisé. Le binding Pgvector.EntityFrameworkCore permet de mapper directement un Vector sur une colonne vector(1536), et de faire e.Embedding.CosineDistance(queryVec) dans LINQ — qui se traduit en embedding <=> query côté Postgres. La requête top-K + scoring + join métadata tient en 20 lignes de C#.
ML.NET reste pertinent pour la classification. Mon HeuristicQuestionClassifier actuel est un baseline (heuristiques + matches keyword), mais le contrat IQuestionClassifier permet de swap pour un modèle ML.NET entraîné dès qu'on a un corpus labellisé. Pas besoin de PyTorch.
Polly v8 + DelegatingChatClient = retry/timeout en 60 lignes. Pas de Hystrix, pas de service mesh, juste un decorator. Tous les calls LLM sont protégés.
Ce que je vais en faire
Vouch est mon anchor enterprise sur le portfolio. C'est le projet que je dépose en entretien quand le recruteur me demande "c'est quoi un SaaS B2B production-grade que t'as construit toi-même ?". 5 projets en solution, 60 tests verts, démo qui tourne offline, docs ARCHITECTURE/ROADMAP/CONTRIBUTING qui montrent que je pense en équipe.
Le roadmap court terme : Hangfire pour l'ingest async, citations cliquables vers le PDF source, JWT-based auth avec claim tenant_id. Le roadmap long terme : consensus generator (OpenAI + Anthropic en parallèle, vote-weighted confidence) et active learning depuis les overrides humains.
Mais surtout, Vouch est la démonstration publique d'un message simple : tu n'as pas besoin de Python pour faire de la GenAI sérieuse en 2026. MEAI + pgvector + EF Core suffisent. Le code est sur le repo. Le demo tourne en 30 secondes. Le reste, c'est de la rhétorique.
Florian Sola
Lead Technique · Haute performance temps réel · 9 ans d'expérience